diff --git a/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLBottomSheet.kt b/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLBottomSheet.kt index 572d3486..8a211dbf 100644 --- a/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLBottomSheet.kt +++ b/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLBottomSheet.kt @@ -1,30 +1,52 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.yapp.ndgl.core.ui.designsystem +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.yapp.ndgl.core.ui.R import com.yapp.ndgl.core.ui.theme.NDGLTheme +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NDGLBottomSheet( modifier: Modifier = Modifier, onDismissRequest: () -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), showDragHandle: Boolean = true, + title: String? = null, content: @Composable ColumnScope.() -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + val hideSheet: () -> Unit = { + coroutineScope.launch { + sheetState.hide() + onDismissRequest() + } + } ModalBottomSheet( modifier = modifier, @@ -37,8 +59,33 @@ fun NDGLBottomSheet( }, containerColor = NDGLTheme.colors.white, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - content = content, - ) + ) { + title?.let { title -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 24.dp), + ) { + Text( + title, + color = NDGLTheme.colors.black400, + style = NDGLTheme.typography.bodyLgMedium, + ) + Spacer(Modifier.weight(1f)) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_24_close), + contentDescription = null, + tint = NDGLTheme.colors.black600, + modifier = Modifier + .size(24.dp) + .clip(shape = CircleShape) + .clickable { hideSheet() }, + ) + } + } + + content() + } } @Preview(showBackground = true) @@ -48,20 +95,15 @@ private fun NDGLBottomSheetWithHandlePreview() { NDGLBottomSheet( onDismissRequest = {}, showDragHandle = true, + title = "바텀시트 제목", ) { Column( modifier = Modifier .fillMaxWidth() .padding(24.dp), ) { - Text( - text = "바텀시트 제목", - style = NDGLTheme.typography.subtitleLgSemiBold, - color = NDGLTheme.colors.black900, - ) Text( text = "바텀시트 내용입니다.", - modifier = Modifier.padding(top = 16.dp), style = NDGLTheme.typography.bodyLgMedium, color = NDGLTheme.colors.black500, ) @@ -70,6 +112,7 @@ private fun NDGLBottomSheetWithHandlePreview() { } } +@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable private fun NDGLBottomSheetWithoutHandlePreview() { @@ -77,20 +120,15 @@ private fun NDGLBottomSheetWithoutHandlePreview() { NDGLBottomSheet( onDismissRequest = {}, showDragHandle = false, + title = "바텀시트 제목", ) { Column( modifier = Modifier .fillMaxWidth() .padding(24.dp), ) { - Text( - text = "바텀시트 제목", - style = NDGLTheme.typography.subtitleLgSemiBold, - color = NDGLTheme.colors.black900, - ) Text( text = "바텀시트 내용입니다.", - modifier = Modifier.padding(top = 16.dp), style = NDGLTheme.typography.bodyLgMedium, color = NDGLTheme.colors.black500, ) diff --git a/core/ui/src/main/java/com/yapp/ndgl/core/ui/util/ReorderableList.kt b/core/ui/src/main/java/com/yapp/ndgl/core/ui/util/ReorderableList.kt new file mode 100644 index 00000000..a26018e5 --- /dev/null +++ b/core/ui/src/main/java/com/yapp/ndgl/core/ui/util/ReorderableList.kt @@ -0,0 +1,200 @@ +package com.yapp.ndgl.core.ui.util + +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private fun SnapshotStateList.reorder( + from: Int, + to: Int, +): Boolean { + if (from == to || from < 0 || to < 0 || from >= this.size || to >= this.size) return false + add(to, removeAt(from)) + return true +} + +fun Modifier.reorderable( + state: ReorderableState, + onUpdateList: (from: Int?, to: Int?) -> Unit = { _, _ -> }, +): Modifier = this.pointerInput(state) { + detectVerticalDragGestures( + onDragStart = { state.onDragStart(it) }, + onDragEnd = { + val from = state.dragStartIndex + val to = state.currentIndex + state.onDragEnd() + onUpdateList(from, to) + }, + onDragCancel = { state.onDragEnd() }, + onVerticalDrag = { _, amount -> state.onDrag(amount) }, + ) +} + +@Composable +fun rememberReorderableState( + list: SnapshotStateList, + lazyListState: LazyListState = rememberLazyListState(), + offset: Int = 0, + isReorderable: (key: Any?) -> Boolean = { true }, +): ReorderableState { + val scope = rememberCoroutineScope() + val currentList by rememberUpdatedState(list) + val onReorder: (Int, Int) -> Boolean = { from, to -> + currentList.reorder(from - offset, to - offset) + } + + return remember { + ReorderableState( + scope = scope, + lazyListState = lazyListState, + onReorder = onReorder, + isReorderable = isReorderable, + ) + } +} + +class ReorderableState( + private val scope: CoroutineScope, + val lazyListState: LazyListState, + private val onReorder: (Int, Int) -> Boolean, + private val isReorderable: (key: Any?) -> Boolean = { true }, +) { + // 드래그 시작한 아이템 상하단 y값 + private var initialYBounds by mutableStateOf(0 to 0) + + // 드래그 거리 + private var distance by mutableFloatStateOf(0f) + + // 드래그 중인 아이템 정보 + private var info: LazyListItemInfo? by mutableStateOf(null) + + // 시작 상하단 y값 + 드래그 거리 + private val currentYBounds: Pair get() = initialYBounds.let { (topY, bottomY) -> topY + distance.toInt() to bottomY + distance.toInt() } + + // 순서 변경 임계값 + private val threshold: Int get() = initialYBounds.let { (it.first + it.second) / 2 + distance.toInt() } + + // 드래그 중인 아이템의 변화하는 인덱스 + val currentIndex: Int? get() = info?.index + + // 드래그 시작 시점의 원본 LazyColumn 인덱스 + var dragStartIndex: Int? = null + private set + + // 오토 스크롤 Job + private var autoScrollJob by mutableStateOf(null) + + // 드래그 시작 - isReorderable이 false인 아이템은 드래그 불가 + fun onDragStart(offset: Offset) { + lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { item -> + offset.y.toInt() > item.offset && offset.y.toInt() < (item.offset + item.size) + } + ?.takeIf { isReorderable(it.key) } + ?.let { + initialYBounds = it.offset to (it.offset + it.size) + info = it + dragStartIndex = it.index + } + } + + // 아이템 인덱스 업데이트 + private fun updateItemIndex() { + val itemInfo = info ?: return + when { + currentYBounds.first < itemInfo.offset && threshold < itemInfo.offset -> + tryMoveUp(itemInfo) + currentYBounds.second > itemInfo.offset + itemInfo.size && threshold > itemInfo.offset + itemInfo.size -> + tryMoveDown(itemInfo) + } + } + + // 위로 드래그할 때 + private fun tryMoveUp(itemInfo: LazyListItemInfo) { + val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index - 1 } + if (target != null) { + // 대상 아이템이 reorderable일 때만 이동 + if (isReorderable(target.key) && onReorder(itemInfo.index, itemInfo.index - 1)) { + info = target + } + } else { + // 오토 스크롤 중 아이템이 화면에 보이지 않을 때 + val firstItem = lazyListState.layoutInfo.visibleItemsInfo.first() + if (isReorderable(firstItem.key) && onReorder(itemInfo.index, firstItem.index)) { + info = lazyListState.layoutInfo.visibleItemsInfo.first() + } + } + } + + // 아래로 드래그할 때 + private fun tryMoveDown(itemInfo: LazyListItemInfo) { + val target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == itemInfo.index + 1 } + if (target != null) { + if (onReorder(itemInfo.index, itemInfo.index + 1)) { + info = target + } + } else { + // 오토 스크롤 중 아이템이 화면에 보이지 않을 때 + val lastItem = lazyListState.layoutInfo.visibleItemsInfo.last() + if (onReorder(itemInfo.index, lastItem.index)) { + info = lazyListState.layoutInfo.visibleItemsInfo.last() + } + } + } + + // 드래그 중 + fun onDrag(amount: Float) { + if (dragStartIndex == null) return + distance += amount + updateItemIndex() + onAutoScroll() + } + + // 드래그 종료 + fun onDragEnd() { + initialYBounds = 0 to 0 + distance = 0f + info = null + dragStartIndex = null + autoScrollJob?.cancel() + autoScrollJob = null + } + + // 오토 스크롤 + private fun onAutoScroll() { + if (autoScrollJob?.isActive == true) return + autoScrollJob = scope.launch(Dispatchers.Main) { + while (true) { + val scrollOffset = when { + currentYBounds.first < lazyListState.layoutInfo.viewportStartOffset -> -16f + currentYBounds.second > lazyListState.layoutInfo.viewportEndOffset -> 16f + else -> null + } + + scrollOffset?.let { + lazyListState.scrollBy(it) + delay(8) + } ?: break + } + } + } +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 306fd7da..a69c4f3d 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -106,4 +106,9 @@ %s -> %s 변경하기 아니요 + + + 이동수단 변경 + 확인 + diff --git a/core/util/src/main/java/com/yapp/ndgl/core/util/IntUtil.kt b/core/util/src/main/java/com/yapp/ndgl/core/util/IntUtil.kt index c37651ce..a8c1f663 100644 --- a/core/util/src/main/java/com/yapp/ndgl/core/util/IntUtil.kt +++ b/core/util/src/main/java/com/yapp/ndgl/core/util/IntUtil.kt @@ -2,5 +2,21 @@ package com.yapp.ndgl.core.util import java.text.NumberFormat import java.util.Locale +import java.util.Locale.getDefault fun Int.formatDecimal(): String = NumberFormat.getInstance(Locale.US).format(this) + +fun Int.formatDistance(): String { + return when { + this >= 1000 -> { + val km = this / 1000.0 + if (km % 1 == 0.0) { + "${km.toInt()}km" + } else { + String.format(getDefault(), "%.1fkm", km) + } + } + + else -> "${this}m" + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt index 6b676783..74aa91bb 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt @@ -1,9 +1,7 @@ package com.yapp.ndgl.feature.travelhelper import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -14,20 +12,16 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @Composable internal fun TravelHelperRoute( - innerPadding: PaddingValues = PaddingValues(), viewModel: TravelHelperViewModel = hiltViewModel(), ) { - TravelHelperScreen(innerPadding = innerPadding) + TravelHelperScreen() } @Composable -private fun TravelHelperScreen( - innerPadding: PaddingValues = PaddingValues(), -) { +private fun TravelHelperScreen() { LazyColumn( modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + .fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -40,5 +34,5 @@ private fun TravelHelperScreen( @Preview(showBackground = true) @Composable private fun TravelHelperScreenPreview() { - TravelHelperScreen(innerPadding = PaddingValues()) + TravelHelperScreen() } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt index 2b76ef45..cd22e59d 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt @@ -9,7 +9,6 @@ import com.yapp.ndgl.core.base.UiSideEffect import com.yapp.ndgl.core.base.UiState import com.yapp.ndgl.core.ui.R import com.yapp.ndgl.core.ui.theme.NDGLTheme -import java.util.Locale.getDefault import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -23,11 +22,10 @@ data class TravelDetailState( val showDeleteModal: Boolean = false, val showCancelEditModal: Boolean = false, val showTimelineBottomSheet: Boolean = false, - val startTime: Duration? = null, - val endTime: Duration? = null, val selectedPlace: TravelPlace? = null, val showPlaceBottomSheet: Boolean = false, val showTimeBottomSheet: Boolean = false, + val showTransportBottomSheet: Boolean = false, val showCostModal: Boolean = false, val showMemoModal: Boolean = false, ) : UiState @@ -65,13 +63,14 @@ data class Budget( } data class Itinerary( - val budget: Budget = Budget(0), + val startTime: Duration? = null, + val endTime: Duration? = null, val places: List = emptyList(), - val transportSegments: List = emptyList(), ) { val totalDuration: Duration - get() = places.fold(0.hours) { acc, place -> acc + place.duration } + - transportSegments.fold(0.hours) { acc, segment -> acc + segment.duration } + get() = places.fold(0.hours) { acc, place -> + acc + place.duration + (place.transportToNext?.duration ?: 0.hours) + } } data class TravelPlace( @@ -88,6 +87,7 @@ data class TravelPlace( val placeType: PlaceType, val userData: UserData = UserData(), val startTime: Duration, + val transportToNext: TransportSegment? = null, ) { val duration: Duration get() = userData.estimatedDuration @@ -122,22 +122,7 @@ data class TransportSegment( val type: TransportType, val duration: Duration, val distance: Int, -) { - fun formatDistance(): String { - return when { - distance >= 1000 -> { - val km = distance / 1000.0 - if (km % 1 == 0.0) { - "${km.toInt()}km" - } else { - String.format(getDefault(), "%.1fkm", km) - } - } - - else -> "${distance}m" - } - } -} +) enum class TransportType(@get:StringRes val labelRes: Int, @get:DrawableRes val iconRes: Int) { WALK(R.string.transport_type_walk, R.drawable.ic_20_walk), @@ -162,8 +147,11 @@ sealed interface TravelDetailIntent : UiIntent { data object LongClickPlaceItem : TravelDetailIntent data object DismissTimelineBottomSheet : TravelDetailIntent data class ConfirmTimelineSetting(val startTime: Duration) : TravelDetailIntent - data class ReorderPlaces(val fromIndex: Int, val toIndex: Int) : TravelDetailIntent + data class ReorderPlaces(val dayIndex: Int, val fromIndex: Int, val toIndex: Int) : TravelDetailIntent data object ConfirmEditMode : TravelDetailIntent + data class ClickTransportSegment(val place: TravelPlace) : TravelDetailIntent + data class ConfirmChangeTransportSegment(val segment: TransportSegment) : TravelDetailIntent + data object DismissTransportBottomSheet : TravelDetailIntent data class ClickPlaceItem(val place: TravelPlace) : TravelDetailIntent data class ClickAddTime(val placeId: Int) : TravelDetailIntent data class ClickAddCost(val placeId: Int) : TravelDetailIntent diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt index b55707fd..3e462d5a 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt @@ -1,8 +1,11 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.yapp.ndgl.feature.travel.traveldetail import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,9 +19,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,12 +33,14 @@ import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -42,6 +49,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.yapp.ndgl.core.ui.R import com.yapp.ndgl.core.ui.designsystem.NDGLBottomSheet @@ -57,6 +65,8 @@ import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationIcon import com.yapp.ndgl.core.ui.theme.NDGLTheme import com.yapp.ndgl.core.ui.util.dropShadow import com.yapp.ndgl.core.ui.util.launchBrowser +import com.yapp.ndgl.core.ui.util.rememberReorderableState +import com.yapp.ndgl.core.ui.util.reorderable import com.yapp.ndgl.feature.travel.traveldetail.component.ContentCard import com.yapp.ndgl.feature.travel.traveldetail.component.DurationPickerContent import com.yapp.ndgl.feature.travel.traveldetail.component.EditControlBar @@ -64,6 +74,7 @@ import com.yapp.ndgl.feature.travel.traveldetail.component.EditablePlaceItem import com.yapp.ndgl.feature.travel.traveldetail.component.PlaceBottomSheet import com.yapp.ndgl.feature.travel.traveldetail.component.PlaceItem import com.yapp.ndgl.feature.travel.traveldetail.component.TimelineContent +import com.yapp.ndgl.feature.travel.traveldetail.component.TransportBottomSheet import com.yapp.ndgl.feature.travel.traveldetail.component.TransportSegment import com.yapp.ndgl.feature.travel.traveldetail.component.TravelDetailToolBar import com.yapp.ndgl.feature.travel.traveldetail.component.TravelMap @@ -113,7 +124,10 @@ internal fun TravelDetailRoute( longClickPlaceItem = { viewModel.onIntent(TravelDetailIntent.LongClickPlaceItem) }, dismissTimelineBottomSheet = { viewModel.onIntent(TravelDetailIntent.DismissTimelineBottomSheet) }, confirmTimelineSetting = { startTime -> viewModel.onIntent(TravelDetailIntent.ConfirmTimelineSetting(startTime)) }, - reorderPlaces = { fromIndex, toIndex -> viewModel.onIntent(TravelDetailIntent.ReorderPlaces(fromIndex, toIndex)) }, + reorderPlaces = { dayIndex, fromIndex, toIndex -> viewModel.onIntent(TravelDetailIntent.ReorderPlaces(dayIndex, fromIndex, toIndex)) }, + clickTransportSegment = { place -> viewModel.onIntent(TravelDetailIntent.ClickTransportSegment(place)) }, + confirmChangeTransport = { segment -> viewModel.onIntent(TravelDetailIntent.ConfirmChangeTransportSegment(segment)) }, + dismissTransportBottomSheet = { viewModel.onIntent(TravelDetailIntent.DismissTransportBottomSheet) }, confirmEditMode = { viewModel.onIntent(TravelDetailIntent.ConfirmEditMode) }, clickPlaceItem = { viewModel.onIntent(TravelDetailIntent.ClickPlaceItem(it)) }, dismissPlaceBottomSheet = { viewModel.onIntent(TravelDetailIntent.DismissPlaceBottomSheet) }, @@ -151,8 +165,11 @@ private fun TravelDetailScreen( longClickPlaceItem: () -> Unit, dismissTimelineBottomSheet: () -> Unit, confirmTimelineSetting: (Duration) -> Unit, - reorderPlaces: (Int, Int) -> Unit, + reorderPlaces: (Int, Int, Int) -> Unit, confirmEditMode: () -> Unit, + clickTransportSegment: (TravelPlace) -> Unit, + confirmChangeTransport: (TransportSegment) -> Unit, + dismissTransportBottomSheet: () -> Unit, clickPlaceItem: (TravelPlace) -> Unit, clickAddTime: (Int) -> Unit, clickAddMemo: (Int) -> Unit, @@ -186,12 +203,24 @@ private fun TravelDetailScreen( result } } - var columnScrollingEnabled by remember { mutableStateOf(true) } + var isMapScrolled by remember { mutableStateOf(true) } val currentItineraries = if (state.isEditMode) state.tempItineraries else state.itineraries val currentItinerary = currentItineraries.getOrNull(state.selectedDay - 1) val currentPlaces = currentItinerary?.places.orEmpty() - val currentTransportSegments = currentItinerary?.transportSegments.orEmpty() + + // 헤더(0) + stickyHeader(1) + 맵 아이템(2) = 3개가 장소 아이템 앞에 위치 + val placesOffset = 3 + val tempPlaces = remember(state.tempItineraries, state.selectedDay) { + state.tempItineraries.getOrNull(state.selectedDay - 1)?.places.orEmpty().toMutableStateList() + } + val reorderableState = rememberReorderableState( + list = tempPlaces, + lazyListState = listState, + offset = placesOffset, + isReorderable = { key -> key is String && key.startsWith("place_") }, + ) + var isDragMode by remember { mutableStateOf(false) } Box( modifier = Modifier @@ -200,10 +229,21 @@ private fun TravelDetailScreen( ) { LazyColumn( state = listState, - userScrollEnabled = columnScrollingEnabled, + userScrollEnabled = isMapScrolled && !isDragMode, modifier = Modifier .fillMaxSize() - .padding(bottom = 60.dp), + .padding(bottom = 60.dp) + .then( + if (state.isEditMode) { + Modifier.reorderable(reorderableState) { from, to -> + if (from != null && to != null && from != to) { + reorderPlaces(state.selectedDay - 1, from - placesOffset, to - placesOffset) + } + } + } else { + Modifier + }, + ), ) { item { Column( @@ -265,7 +305,7 @@ private fun TravelDetailScreen( if (itinerary.places.isNotEmpty()) { TravelMap( places = itinerary.places, - onScrollEnabledChange = { columnScrollingEnabled = it }, + onScrollEnabledChange = { isMapScrolled = it }, ) if (state.isEditMode) { val currentDayPlaceIds = itinerary.places.map { it.id }.toSet() @@ -278,7 +318,7 @@ private fun TravelDetailScreen( ) } else { TravelDetailToolBar( - startTime = state.startTime, + startTime = itinerary.startTime, clickStartTimeSetting = clickStartTimeSetting, clickEditTravel = clickEditTravel, ) @@ -307,22 +347,46 @@ private fun TravelDetailScreen( } } - item(key = "places_${state.selectedDay}") { - Column( - modifier = Modifier - .fillMaxWidth() - .clipToBounds(), - verticalArrangement = Arrangement.spacedBy(if (state.isEditMode) 16.dp else 10.dp), - ) { - currentPlaces.forEachIndexed { index, place -> - key("place_${state.selectedDay}_${place.id}") { - if (state.isEditMode) { - EditablePlaceItem( - place = place, - checked = state.selectedPlaceIds.contains(place.id), - onCheck = { checkPlaceItem(place.id) }, + if (state.isEditMode) { + itemsIndexed( + items = tempPlaces, + key = { _, place -> "place_${state.selectedDay}_${place.id}" }, + ) { index, place -> + val isDragging = reorderableState.currentIndex == index + placesOffset + Box( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .zIndex(if (isDragging) 1f else 0f) + .padding(bottom = 16.dp) + .background(if (isDragging) NDGLTheme.colors.black50.copy(0.9f) else Color.Transparent) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isDragMode = true + tryAwaitRelease() + isDragMode = false + }, ) - } else { + }, + ) { + EditablePlaceItem( + place = place, + checked = state.selectedPlaceIds.contains(place.id), + onCheck = { checkPlaceItem(place.id) }, + ) + } + } + } else { + item(key = "places_${state.selectedDay}") { + Column( + modifier = Modifier + .fillMaxWidth() + .clipToBounds(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + currentPlaces.forEachIndexed { index, place -> + key("place_${state.selectedDay}_${place.id}") { Box(modifier = Modifier.padding(horizontal = 24.dp)) { PlaceItem( place = place, @@ -331,13 +395,13 @@ private fun TravelDetailScreen( ) } } - } - if (!state.isEditMode && index < currentPlaces.size - 1) { - key("transport_${state.selectedDay}_${place.id}") { - currentTransportSegments.getOrNull(index)?.let { segment -> - Box(modifier = Modifier.padding(horizontal = 24.dp)) { - TransportSegment(segment = segment) + if (index < currentPlaces.size - 1) { + key("transport_${state.selectedDay}_${place.id}") { + place.transportToNext?.let { segment -> + Box(modifier = Modifier.padding(horizontal = 24.dp)) { + TransportSegment(segment = segment, onClick = { clickTransportSegment(place) }) + } } } } @@ -432,16 +496,35 @@ private fun TravelDetailScreen( NDGLBottomSheet( onDismissRequest = dismissTimelineBottomSheet, showDragHandle = false, + title = stringResource(R.string.schedule_setting_title), ) { TimelineContent( - startTime = state.startTime ?: 8.hours, + startTime = currentItinerary?.startTime ?: 8.hours, totalDuration = state.itineraries.getOrNull(state.selectedDay - 1)?.totalDuration ?: 0.hours, - onDismissRequest = dismissTimelineBottomSheet, onConfirm = confirmTimelineSetting, ) } } + if (state.showTransportBottomSheet && state.selectedPlace != null && state.selectedPlace.transportToNext != null) { + // TODO: 실제 교통수단 후보로 수정 + val mockAvailableTransports = mutableListOf( + TransportSegment(TransportType.WALK, 15.minutes, 1200), + TransportSegment(TransportType.CAR, 10.minutes, 5400), + TransportSegment(TransportType.BUS, 25.minutes, 4800), + TransportSegment(TransportType.TRAIN, 40.minutes, 12000), + ) + mockAvailableTransports.remove(state.selectedPlace.transportToNext) + mockAvailableTransports.add(state.selectedPlace.transportToNext) + + TransportBottomSheet( + initialTransport = state.selectedPlace.transportToNext, + availableTransports = mockAvailableTransports, + onDismissRequest = dismissTransportBottomSheet, + onConfirm = confirmChangeTransport, + ) + } + if (state.showPlaceBottomSheet && state.selectedPlace != null) { PlaceBottomSheet( place = state.selectedPlace, @@ -551,7 +634,6 @@ private fun TravelDetailScreenPreview() { selectedDay = 1, itineraries = listOf( Itinerary( - budget = Budget(300000), places = listOf( TravelPlace( id = 1, @@ -566,7 +648,8 @@ private fun TravelDetailScreenPreview() { googleMapsUri = "", placeType = PlaceType.ATTRACTION, userData = TravelPlace.UserData(estimatedDuration = 90.minutes), - startTime = 0.hours, + transportToNext = TransportSegment(type = TransportType.CAR, duration = 25.minutes, distance = 3500), + startTime = 8.hours, ), TravelPlace( id = 2, @@ -582,13 +665,7 @@ private fun TravelDetailScreenPreview() { placeType = PlaceType.RESTAURANT, userData = TravelPlace.UserData(estimatedDuration = 60.minutes), startTime = 0.hours, - ), - ), - transportSegments = listOf( - TransportSegment( - type = TransportType.CAR, - duration = 25.minutes, - distance = 3500, + transportToNext = null, ), ), ), @@ -611,7 +688,7 @@ private fun TravelDetailScreenPreview() { longClickPlaceItem = {}, dismissTimelineBottomSheet = {}, confirmTimelineSetting = {}, - reorderPlaces = { _, _ -> }, + reorderPlaces = { _, _, _ -> }, confirmEditMode = {}, clickPlaceItem = {}, clickAddTime = {}, @@ -626,6 +703,9 @@ private fun TravelDetailScreenPreview() { confirmCost = { _ -> }, dismissMemoModal = {}, confirmMemo = { _ -> }, + clickTransportSegment = {}, + confirmChangeTransport = { _ -> }, + dismissTransportBottomSheet = {}, ) } } @@ -655,7 +735,6 @@ private fun TravelDetailScreenEditModePreview() { selectedDay = 1, itineraries = listOf( Itinerary( - budget = Budget(300000), places = listOf( TravelPlace( id = 1, @@ -670,6 +749,7 @@ private fun TravelDetailScreenEditModePreview() { googleMapsUri = "", placeType = PlaceType.ATTRACTION, userData = TravelPlace.UserData(estimatedDuration = 90.minutes), + transportToNext = TransportSegment(type = TransportType.CAR, duration = 25.minutes, distance = 3500), startTime = 0.hours, ), TravelPlace( @@ -684,14 +764,9 @@ private fun TravelDetailScreenEditModePreview() { regularOpeningHours = "11:00~22:00", googleMapsUri = "", placeType = PlaceType.RESTAURANT, - userData = TravelPlace.UserData(estimatedDuration = 60.minutes), startTime = 0.hours, - ), - ), - transportSegments = listOf( - TransportSegment( - type = TransportType.CAR, - duration = 25.minutes, - distance = 3500, + userData = TravelPlace.UserData(estimatedDuration = 60.minutes), + transportToNext = null, + startTime = 8.hours, ), ), ), @@ -714,7 +789,7 @@ private fun TravelDetailScreenEditModePreview() { longClickPlaceItem = {}, dismissTimelineBottomSheet = {}, confirmTimelineSetting = {}, - reorderPlaces = { _, _ -> }, + reorderPlaces = { _, _, _ -> }, confirmEditMode = {}, clickPlaceItem = {}, clickAddTime = {}, @@ -729,6 +804,9 @@ private fun TravelDetailScreenEditModePreview() { confirmCost = { _ -> }, dismissMemoModal = {}, confirmMemo = { _ -> }, + clickTransportSegment = {}, + confirmChangeTransport = { _ -> }, + dismissTransportBottomSheet = {}, ) } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt index 4e3233ad..e3962aa5 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt @@ -28,18 +28,15 @@ class TravelDetailViewModel @AssistedInject constructor( private fun applyDefaultStartTime() { reduce { - val baseTime = startTime ?: DEFAULT_START_TIME.hours val updatedItineraries = itineraries.map { itinerary -> + val baseStartTime = itinerary.startTime ?: DEFAULT_START_TIME.hours + val updatedPlaces = calculatePlaceStartTimes(itinerary.places, baseStartTime) itinerary.copy( - places = calculatePlaceStartTimes(itinerary.places, itinerary.transportSegments, baseTime), + places = updatedPlaces, ) } - val totalDuration = updatedItineraries.getOrNull(selectedDay - 1)?.totalDuration ?: 0.hours - copy( - itineraries = updatedItineraries, - tempItineraries = updatedItineraries, - endTime = if (startTime != null) baseTime + totalDuration else endTime, - ) + + copy(itineraries = updatedItineraries) } } @@ -47,7 +44,6 @@ class TravelDetailViewModel @AssistedInject constructor( // TODO: Load from repository val loadedItineraries = listOf( Itinerary( - budget = Budget(300000), places = listOf( TravelPlace( id = 1, @@ -62,6 +58,7 @@ class TravelDetailViewModel @AssistedInject constructor( googleMapsUri = "", placeType = PlaceType.ATTRACTION, userData = TravelPlace.UserData(estimatedDuration = 90.minutes), + transportToNext = TransportSegment(type = TransportType.CAR, duration = 25.minutes, distance = 3500), startTime = 0.hours, ), TravelPlace( @@ -77,6 +74,7 @@ class TravelDetailViewModel @AssistedInject constructor( googleMapsUri = "", placeType = PlaceType.RESTAURANT, userData = TravelPlace.UserData(estimatedDuration = 60.minutes), + transportToNext = TransportSegment(type = TransportType.WALK, duration = 10.minutes, distance = 800), startTime = 0.hours, ), TravelPlace( @@ -93,6 +91,7 @@ class TravelDetailViewModel @AssistedInject constructor( placeType = PlaceType.ACCOMMODATION, userData = TravelPlace.UserData(estimatedDuration = 120.minutes), startTime = 0.hours, + transportToNext = TransportSegment(type = TransportType.CAR, duration = 15.minutes, distance = 2100), ), TravelPlace( id = 4, @@ -108,16 +107,11 @@ class TravelDetailViewModel @AssistedInject constructor( placeType = PlaceType.RESTAURANT, userData = TravelPlace.UserData(estimatedDuration = 90.minutes), startTime = 0.hours, + transportToNext = null, ), ), - transportSegments = listOf( - TransportSegment(type = TransportType.CAR, duration = 25.minutes, distance = 3500), - TransportSegment(type = TransportType.WALK, duration = 10.minutes, distance = 800), - TransportSegment(type = TransportType.CAR, duration = 15.minutes, distance = 2100), - ), ), Itinerary( - budget = Budget(250000), places = listOf( TravelPlace( id = 8, @@ -133,6 +127,7 @@ class TravelDetailViewModel @AssistedInject constructor( placeType = PlaceType.ATTRACTION, userData = TravelPlace.UserData(estimatedDuration = 90.minutes), startTime = 0.hours, + transportToNext = TransportSegment(type = TransportType.WALK, duration = 8.minutes, distance = 600), ), TravelPlace( id = 9, @@ -148,12 +143,9 @@ class TravelDetailViewModel @AssistedInject constructor( placeType = PlaceType.CAFE, userData = TravelPlace.UserData(estimatedDuration = 45.minutes), startTime = 0.hours, + transportToNext = null, ), ), - transportSegments = listOf( - TransportSegment(type = TransportType.WALK, duration = 8.minutes, distance = 600), - TransportSegment(type = TransportType.TRAIN, duration = 30.minutes, distance = 8500), - ), ), ) @@ -164,8 +156,8 @@ class TravelDetailViewModel @AssistedInject constructor( country = "태국", city = "방콕", budgetPerPerson = Budget(1200000), - nights = 3, - days = 4, + nights = 1, + days = 2, videoInfo = VideoInfo( title = "방콕 풀코스, 동남아 안 가본 곽튜브와 함께 【방콕】", name = "빠니보틀", @@ -198,8 +190,11 @@ class TravelDetailViewModel @AssistedInject constructor( is TravelDetailIntent.LongClickPlaceItem -> longClickPlaceItem() is TravelDetailIntent.DismissTimelineBottomSheet -> dismissTimelineBottomSheet() is TravelDetailIntent.ConfirmTimelineSetting -> confirmTimelineSetting(intent.startTime) - is TravelDetailIntent.ReorderPlaces -> reorderPlaces(intent.fromIndex, intent.toIndex) + is TravelDetailIntent.ReorderPlaces -> reorderPlaces(intent.dayIndex, intent.fromIndex, intent.toIndex) is TravelDetailIntent.ConfirmEditMode -> confirmEditMode() + is TravelDetailIntent.ClickTransportSegment -> clickTransportSegment(intent.place) + is TravelDetailIntent.DismissTransportBottomSheet -> dismissTransportBottomSheet() + is TravelDetailIntent.ConfirmChangeTransportSegment -> confirmChangeTransportSegment(intent.segment) is TravelDetailIntent.ClickPlaceItem -> clickPlaceItem(intent.place) is TravelDetailIntent.DismissPlaceBottomSheet -> dismissPlaceBottomSheet() is TravelDetailIntent.NavigateToPlaceDetail -> navigateToPlaceDetail(intent.placeId) @@ -282,17 +277,8 @@ class TravelDetailViewModel @AssistedInject constructor( val updatedItineraries = tempItineraries.map { itinerary -> val remainingPlaces = itinerary.places .filter { it.id !in selectedPlaceIds } - .mapIndexed { newIndex, place -> - place.copy(sequence = newIndex + 1) - } - - // TODO Place 삭제하면 교통수단 어떻게 다시 들어갈지 고민 필요 - val remainingTransportCount = (remainingPlaces.size - 1).coerceAtLeast(0) - val remainingTransports = itinerary.transportSegments.take(remainingTransportCount) - itinerary.copy( - places = remainingPlaces, - transportSegments = remainingTransports, - ) + .mapIndexed { newIndex, place -> place.copy(sequence = newIndex + 1) } + itinerary.copy(places = recalculateTransportSegments(remainingPlaces)) } copy( @@ -341,19 +327,17 @@ class TravelDetailViewModel @AssistedInject constructor( val updatedItineraries = itineraries.mapIndexed { index, itinerary -> if (index == dayIndex) { itinerary.copy( - places = calculatePlaceStartTimes(itinerary.places, itinerary.transportSegments, startTime), + places = calculatePlaceStartTimes(itinerary.places, startTime), + startTime = startTime, + endTime = startTime + itinerary.totalDuration, ) } else { itinerary } } - val totalDuration = updatedItineraries.getOrNull(dayIndex)?.totalDuration ?: 0.hours copy( - startTime = startTime, - endTime = startTime + totalDuration, itineraries = updatedItineraries, - tempItineraries = updatedItineraries, showTimelineBottomSheet = false, ) } @@ -361,42 +345,28 @@ class TravelDetailViewModel @AssistedInject constructor( private fun calculatePlaceStartTimes( places: List, - transportSegments: List, startTime: Duration, ): List { if (places.isEmpty()) return places var currentTime = startTime - return places.mapIndexed { index, place -> + return places.map { place -> val updatedPlace = place.copy(startTime = currentTime) - currentTime += place.duration - if (index < transportSegments.size) { - currentTime += transportSegments[index].duration - } + currentTime += place.duration + (place.transportToNext?.duration ?: 0.hours) updatedPlace } } - private fun reorderPlaces(fromIndex: Int, toIndex: Int) { + private fun reorderPlaces(dayIndex: Int, fromIndex: Int, toIndex: Int) { reduce { - val updatedItineraries = tempItineraries.map { itinerary -> + val updatedItineraries = tempItineraries.mapIndexed { index, itinerary -> + if (index != dayIndex) return@mapIndexed itinerary val mutablePlaces = itinerary.places.toMutableList() - - if (fromIndex in mutablePlaces.indices && toIndex in mutablePlaces.indices) { - val movedItem = mutablePlaces.removeAt(fromIndex) - mutablePlaces.add(toIndex, movedItem) - - val reorderedPlaces = mutablePlaces.mapIndexed { index, place -> - place.copy(sequence = index + 1) - } - - itinerary.copy( - places = reorderedPlaces, - ) - } else { - itinerary - } + if (fromIndex !in mutablePlaces.indices || toIndex !in mutablePlaces.indices) return@mapIndexed itinerary + val movedItem = mutablePlaces.removeAt(fromIndex) + mutablePlaces.add(toIndex, movedItem) + val resequenced = mutablePlaces.mapIndexed { i, place -> place.copy(sequence = i + 1) } + itinerary.copy(places = recalculateTransportSegments(resequenced)) } - copy(tempItineraries = updatedItineraries) } } @@ -411,6 +381,44 @@ class TravelDetailViewModel @AssistedInject constructor( } } + private fun clickTransportSegment(place: TravelPlace) { + reduce { + copy( + selectedPlace = place, + showTransportBottomSheet = true, + ) + } + } + + private fun confirmChangeTransportSegment(newTransportSegment: TransportSegment) { + reduce { + val updatedItineraries = itineraries.mapIndexed { index, dayItinerary -> + if (index == selectedDay - 1) { + val updatedPlaces = dayItinerary.places.map { place -> + if (place.id == selectedPlace?.id) { + place.copy(transportToNext = newTransportSegment) + } else { + place + } + } + val timedPlaces = calculatePlaceStartTimes( + places = updatedPlaces, + startTime = dayItinerary.startTime ?: DEFAULT_START_TIME.hours, + ) + + dayItinerary.copy(places = timedPlaces) + } else { + dayItinerary + } + } + copy(itineraries = updatedItineraries, selectedPlace = null, showTransportBottomSheet = false) + } + } + + private fun dismissTransportBottomSheet() { + reduce { copy(selectedPlace = null, showTransportBottomSheet = false) } + } + private fun clickPlaceItem(place: TravelPlace) { reduce { copy( @@ -453,7 +461,6 @@ class TravelDetailViewModel @AssistedInject constructor( private fun confirmDuration(duration: Duration) { reduce { - var updatedPlace: TravelPlace? = null val updatedItineraries = itineraries.map { itinerary -> if (itinerary.places.none { it.id == selectedPlace?.id }) return@map itinerary @@ -464,17 +471,14 @@ class TravelDetailViewModel @AssistedInject constructor( place } } - val firstPlaceStartTime = durationUpdatedPlaces.firstOrNull()?.startTime ?: DEFAULT_START_TIME.hours - val recalculatedPlaces = - calculatePlaceStartTimes(durationUpdatedPlaces, itinerary.transportSegments, firstPlaceStartTime) - updatedPlace = recalculatedPlaces.find { it.id == selectedPlace?.id } - itinerary.copy(places = recalculatedPlaces) + itinerary.copy( + places = calculatePlaceStartTimes(durationUpdatedPlaces, itinerary.startTime ?: DEFAULT_START_TIME.hours), + endTime = (itinerary.startTime ?: DEFAULT_START_TIME.hours) + itinerary.totalDuration, + ) } - val totalDuration = updatedItineraries.getOrNull(selectedDay - 1)?.totalDuration ?: 0.hours copy( itineraries = updatedItineraries, - selectedPlace = updatedPlace, - endTime = (startTime ?: DEFAULT_START_TIME.hours) + totalDuration, + selectedPlace = null, showTimeBottomSheet = false, ) } @@ -552,6 +556,20 @@ class TravelDetailViewModel @AssistedInject constructor( postSideEffect(TravelDetailSideEffect.NavigateToBrowser(url)) } + // TODO: 실제 라우팅 API 연동 시 교체 + private fun createTransportSegment(from: TravelPlace, to: TravelPlace): TransportSegment { + return TransportSegment(type = TransportType.CAR, duration = 15.minutes, distance = 1000) + } + + private fun recalculateTransportSegments(places: List): List { + return places.mapIndexed { index, place -> + val nextPlace = places.getOrNull(index + 1) + place.copy( + transportToNext = if (nextPlace != null) createTransportSegment(place, nextPlace) else null, + ) + } + } + @AssistedFactory interface Factory { fun create(travelId: Int): TravelDetailViewModel diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceBottomSheet.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceBottomSheet.kt index 80a9a9d7..08a34e7a 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceBottomSheet.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceBottomSheet.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.yapp.ndgl.feature.travel.traveldetail.component import androidx.compose.foundation.background @@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceItem.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceItem.kt index d36a9082..68566bc3 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceItem.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/PlaceItem.kt @@ -91,7 +91,7 @@ internal fun EditablePlaceItem( modifier = Modifier .fillMaxWidth() .background( - color = NDGLTheme.colors.white, + color = Color.Transparent, shape = RoundedCornerShape(16.dp), ) .padding(horizontal = 0.dp), diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TimelineContent.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TimelineContent.kt index ee46ec4e..49972705 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TimelineContent.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TimelineContent.kt @@ -56,7 +56,6 @@ import kotlin.time.Duration.Companion.minutes internal fun TimelineContent( startTime: Duration, totalDuration: Duration, - onDismissRequest: () -> Unit, onConfirm: (Duration) -> Unit, ) { var isSettingStartTime by remember { mutableStateOf(false) } @@ -68,33 +67,6 @@ internal fun TimelineContent( .fillMaxWidth() .padding(24.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.schedule_setting_title), - color = NDGLTheme.colors.black900, - style = NDGLTheme.typography.titleMdSemiBold, - ) - Spacer(Modifier.weight(1f)) - Box( - modifier = Modifier - .size(32.dp) - .clip(RoundedCornerShape(8.dp)) - .background(NDGLTheme.colors.black50) - .clickable { onDismissRequest() } - .padding(8.dp), - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_24_close), - contentDescription = null, - tint = NDGLTheme.colors.black900, - ) - } - } - Spacer(Modifier.height(32.dp)) - if (isSettingStartTime) { val hourItems = remember { (0..23).toList() } val minuteItems = remember { (0..55 step 5).toList() } @@ -323,7 +295,6 @@ private fun TimelineContentPreview() { TimelineContent( startTime = 9.hours, totalDuration = 16.hours, - onDismissRequest = {}, onConfirm = {}, ) } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TransportBottomSheet.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TransportBottomSheet.kt new file mode 100644 index 00000000..3ce805b0 --- /dev/null +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TransportBottomSheet.kt @@ -0,0 +1,181 @@ +package com.yapp.ndgl.feature.travel.traveldetail.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.yapp.ndgl.core.ui.R +import com.yapp.ndgl.core.ui.designsystem.NDGLBottomSheet +import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButton +import com.yapp.ndgl.core.ui.designsystem.NDGLCTAButtonAttr +import com.yapp.ndgl.core.ui.theme.NDGLTheme +import com.yapp.ndgl.core.ui.util.noRippleClickable +import com.yapp.ndgl.core.util.formatDistance +import com.yapp.ndgl.core.util.formatString +import com.yapp.ndgl.feature.travel.traveldetail.TransportSegment +import com.yapp.ndgl.feature.travel.traveldetail.TransportType +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.minutes + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TransportBottomSheet( + initialTransport: TransportSegment, + availableTransports: List, + onDismissRequest: () -> Unit, + onConfirm: (TransportSegment) -> Unit, +) { + var selectedTransport by remember { mutableStateOf(initialTransport) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + val itemHeight = 56.dp + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return available + } + } + } + + NDGLBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + showDragHandle = false, + title = stringResource(R.string.transport_bottom_sheet_title), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight * 4) + .nestedScroll(nestedScrollConnection), + ) { + itemsIndexed(availableTransports) { index, transport -> + val isSelected = selectedTransport == transport + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(if (isSelected) NDGLTheme.colors.black100 else Color.Transparent) + .noRippleClickable { + selectedTransport = transport + } + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(transport.type.iconRes), + contentDescription = stringResource(transport.type.labelRes), + tint = if (isSelected) NDGLTheme.colors.black600 else NDGLTheme.colors.black400, + ) + Spacer(Modifier.width(32.dp)) + Text( + stringResource(transport.type.labelRes), + color = if (isSelected) NDGLTheme.colors.black700 else NDGLTheme.colors.black400, + style = NDGLTheme.typography.subtitleMdSemiBold, + ) + Spacer(Modifier.width(8.dp)) + Text( + transport.duration.formatString(), + color = if (isSelected) NDGLTheme.colors.black400 else NDGLTheme.colors.black300, + style = NDGLTheme.typography.bodyLgMedium, + ) + Spacer(Modifier.width(8.dp)) + Text( + "•", + color = if (isSelected) NDGLTheme.colors.black400 else NDGLTheme.colors.black300, + style = NDGLTheme.typography.bodyLgMedium, + ) + Spacer(Modifier.width(8.dp)) + Text( + transport.distance.formatDistance(), + color = if (isSelected) NDGLTheme.colors.black400 else NDGLTheme.colors.black300, + style = NDGLTheme.typography.bodyLgMedium, + ) + } + } + } + NDGLCTAButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + type = NDGLCTAButtonAttr.Type.PRIMARY, + size = NDGLCTAButtonAttr.Size.LARGE, + status = NDGLCTAButtonAttr.Status.ACTIVE, + label = stringResource(R.string.transport_bottom_sheet_button), + onClick = { + coroutineScope.launch { + onConfirm(selectedTransport) + sheetState.hide() + } + }, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TransportBottomSheetPreview() { + // 1. Mock Data 준비 + val mockAvailableTransports = listOf( + TransportSegment(TransportType.WALK, 15.minutes, 1200), + TransportSegment(TransportType.CAR, 10.minutes, 5400), + TransportSegment(TransportType.BUS, 25.minutes, 4800), + TransportSegment(TransportType.TRAIN, 40.minutes, 12000), + TransportSegment(TransportType.CAR, 5.minutes, 2000), // 스크롤 확인용 추가 + ) + + NDGLTheme { + Box(Modifier.fillMaxSize()) { + TransportBottomSheet( + initialTransport = mockAvailableTransports[0], + availableTransports = mockAvailableTransports, + onDismissRequest = {}, + onConfirm = {}, + ) + } + } +} diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TransportSegment.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TransportSegment.kt index e4024a22..ccd931d5 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TransportSegment.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TransportSegment.kt @@ -1,6 +1,5 @@ package com.yapp.ndgl.feature.travel.traveldetail.component -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -18,6 +17,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.yapp.ndgl.core.ui.R import com.yapp.ndgl.core.ui.theme.NDGLTheme +import com.yapp.ndgl.core.ui.util.noRippleClickable +import com.yapp.ndgl.core.util.formatDistance import com.yapp.ndgl.core.util.formatString import com.yapp.ndgl.feature.travel.traveldetail.TransportSegment import com.yapp.ndgl.feature.travel.traveldetail.TransportType @@ -26,12 +27,12 @@ import kotlin.time.Duration.Companion.minutes @Composable internal fun TransportSegment( segment: TransportSegment, - onClick: () -> Unit = {}, + onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable { + .noRippleClickable { onClick() }, verticalAlignment = Alignment.CenterVertically, @@ -43,7 +44,7 @@ internal fun TransportSegment( text = stringResource( R.string.transport_segment_format, segment.duration.formatString(), - segment.formatDistance(), + segment.distance.formatDistance(), ), color = NDGLTheme.colors.black400, style = NDGLTheme.typography.bodyMdRegular, @@ -62,6 +63,7 @@ internal fun TransportSegment( private fun TransportSegmentPreview() { NDGLTheme { TransportSegment( + onClick = {}, segment = TransportSegment( type = TransportType.WALK, duration = 15.minutes, diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TravelMap.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TravelMap.kt index 5910bb43..90d66588 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TravelMap.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/component/TravelMap.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -88,8 +89,7 @@ internal fun TravelMap( ) { GoogleMap( modifier = Modifier - .fillMaxWidth() - .height(150.dp) + .matchParentSize() .clip(RoundedCornerShape(12.dp)) .motionEventSpy { when (it.action) { @@ -120,7 +120,9 @@ internal fun TravelMap( ) places.forEach { place -> - PlaceMarker(place = place) + key(place.id, place.sequence) { + PlaceMarker(place = place) + } } } }