diff --git a/build-logic/src/main/java/com/ondot/build_logic/convention/ComposeMultiplatformConventionPlugin.kt b/build-logic/src/main/java/com/ondot/build_logic/convention/ComposeMultiplatformConventionPlugin.kt index bcb7886f..f857c667 100644 --- a/build-logic/src/main/java/com/ondot/build_logic/convention/ComposeMultiplatformConventionPlugin.kt +++ b/build-logic/src/main/java/com/ondot/build_logic/convention/ComposeMultiplatformConventionPlugin.kt @@ -45,6 +45,10 @@ class ComposeMultiplatformConventionPlugin: Plugin { addComposeCommonDependencies(libs) + dependencies.add( + "debugImplementation", + libs.findLibrary("compose-uiTooling").get(), + ) // TODO(이후에 feature 모듈로 분리되어야 함) extensions.configure { @@ -106,4 +110,4 @@ class ComposeMultiplatformConventionPlugin: Plugin { } } } -} \ No newline at end of file +} diff --git a/build-logic/src/main/java/com/ondot/build_logic/convention/internal/Extensions.kt b/build-logic/src/main/java/com/ondot/build_logic/convention/internal/Extensions.kt index 81e8355d..e576b15f 100644 --- a/build-logic/src/main/java/com/ondot/build_logic/convention/internal/Extensions.kt +++ b/build-logic/src/main/java/com/ondot/build_logic/convention/internal/Extensions.kt @@ -12,6 +12,7 @@ fun Project.addComposeCommonDependencies(libs: VersionCatalog) { implementation(libs.findLibrary("compose-foundation").get()) implementation(libs.findLibrary("compose-ui").get()) implementation(libs.findLibrary("compose-material3").get()) + implementation(libs.findLibrary("compose-components-ui-tooling-preview").get()) implementation(libs.findLibrary("compose-components-resources").get()) } } @@ -19,4 +20,4 @@ fun Project.addComposeCommonDependencies(libs: VersionCatalog) { fun Project.applyOnce(id: String) { if (!plugins.hasPlugin(id)) pluginManager.apply(id) -} \ No newline at end of file +} diff --git a/core/design-system/src/commonMain/kotlin/com/ondot/designsystem/components/RouteInputSection.kt b/core/design-system/src/commonMain/kotlin/com/ondot/designsystem/components/RouteInputSection.kt index 65786c21..cddff584 100644 --- a/core/design-system/src/commonMain/kotlin/com/ondot/designsystem/components/RouteInputSection.kt +++ b/core/design-system/src/commonMain/kotlin/com/ondot/designsystem/components/RouteInputSection.kt @@ -52,6 +52,7 @@ fun RouteInputSection( departureFocusRequester: FocusRequester = FocusRequester(), arrivalFocusRequester: FocusRequester = FocusRequester(), onRouteInputChanged: (String) -> Unit = {}, + onRouteInputChangedByType: (RouterType, String) -> Unit = { _, value -> onRouteInputChanged(value) }, onRouteInputFocused: (RouterType) -> Unit = {}, ) { Column( @@ -67,7 +68,7 @@ fun RouteInputSection( input = departurePlaceInput, readOnly = readOnly, focusRequester = departureFocusRequester, - onValueChanged = onRouteInputChanged, + onValueChanged = { onRouteInputChangedByType(RouterType.Departure, it) }, onRouteInputFocused = onRouteInputFocused, ) @@ -82,7 +83,7 @@ fun RouteInputSection( input = arrivalPlaceInput, readOnly = readOnly, focusRequester = arrivalFocusRequester, - onValueChanged = onRouteInputChanged, + onValueChanged = { onRouteInputChangedByType(RouterType.Arrival, it) }, onRouteInputFocused = onRouteInputFocused, ) } @@ -140,6 +141,7 @@ fun RouteInputTextField( } }, readOnly = readOnly, + enabled = !readOnly, singleLine = true, textStyle = OnDotTypo().bodyLargeR1.copy(color = Gray0), cursorBrush = SolidColor(Gray0), diff --git a/core/design-system/src/commonMain/kotlin/com/ondot/designsystem/preview/OnDotPreview.kt b/core/design-system/src/commonMain/kotlin/com/ondot/designsystem/preview/OnDotPreview.kt new file mode 100644 index 00000000..02cfe1a9 --- /dev/null +++ b/core/design-system/src/commonMain/kotlin/com/ondot/designsystem/preview/OnDotPreview.kt @@ -0,0 +1,37 @@ +package com.ondot.designsystem.preview + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.dh.ondot.presentation.ui.theme.OnDotTheme +import com.ondot.designsystem.components.OnDotText +import com.ondot.designsystem.theme.OnDotColor.Gray0 +import com.ondot.designsystem.theme.OnDotColor.Gray900 +import com.ondot.domain.model.enums.OnDotTextStyle +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Preview +@Composable +private fun OnDotTextPreview() { + OnDotTheme { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Gray0) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + OnDotText( + text = "OnDot Preview", + style = OnDotTextStyle.BodyLargeSB, + color = Gray900, + ) + } + } +} diff --git a/core/navigation/src/commonMain/kotlin/com/ondot/navigation/AppNavHost.kt b/core/navigation/src/commonMain/kotlin/com/ondot/navigation/AppNavHost.kt index 3c69ed4c..12304fdd 100644 --- a/core/navigation/src/commonMain/kotlin/com/ondot/navigation/AppNavHost.kt +++ b/core/navigation/src/commonMain/kotlin/com/ondot/navigation/AppNavHost.kt @@ -109,6 +109,8 @@ private fun String.toScreenViewEventName(): String = // 패턴 라우트는 ROUTE로 매칭 NavRoutes.EditSchedule.ROUTE -> "screen_view_edit_schedule" + NavRoutes.EditPlacePicker.route -> "screen_view_edit_place_picker" + NavRoutes.EditRouteLoading.route -> "screen_view_edit_route_loading" NavRoutes.PreparationAlarm.ROUTE -> "screen_view_preparation_alarm" NavRoutes.DepartureAlarm.ROUTE -> "screen_view_departure_alarm" NavRoutes.ServiceTerms.ROUTE -> "screen_view_service_terms" diff --git a/core/navigation/src/commonMain/kotlin/com/ondot/navigation/NavRoutes.kt b/core/navigation/src/commonMain/kotlin/com/ondot/navigation/NavRoutes.kt index 30b09b2d..11c2d49e 100644 --- a/core/navigation/src/commonMain/kotlin/com/ondot/navigation/NavRoutes.kt +++ b/core/navigation/src/commonMain/kotlin/com/ondot/navigation/NavRoutes.kt @@ -102,6 +102,10 @@ sealed class NavRoutes( } } + data object EditPlacePicker : NavRoutes("editPlacePicker") + + data object EditRouteLoading : NavRoutes("editRouteLoading") + // DeleteAccount data object DeleteAccountGraph : NavRoutes("deleteAccountGraph") diff --git a/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/loading/RouteLoadingScreen.kt b/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/loading/RouteLoadingScreen.kt index d1228424..2ad61351 100644 --- a/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/loading/RouteLoadingScreen.kt +++ b/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/loading/RouteLoadingScreen.kt @@ -29,6 +29,7 @@ import ondot.core.design_system.generated.resources.Res @Composable fun RouteLoadingScreen( label: String = ROUTE_CALCULATE_LABEL, + autoNavigate: Boolean = true, navigateToNext: () -> Unit, ) { val composition by rememberLottieComposition { @@ -39,9 +40,11 @@ fun RouteLoadingScreen( iterations = Compottie.IterateForever, ) - LaunchedEffect(Unit) { - delay(2000L) - navigateToNext() + LaunchedEffect(autoNavigate) { + if (autoNavigate) { + delay(2000L) + navigateToNext() + } } Box( diff --git a/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/placepicker/PlacePicker.kt b/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/placepicker/PlacePicker.kt index 09c0ef05..3d71d619 100644 --- a/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/placepicker/PlacePicker.kt +++ b/core/ui/src/commonMain/kotlin/com/ondot/ui/screen/placepicker/PlacePicker.kt @@ -68,6 +68,7 @@ fun PlacePickerScreen( departureFocusRequester: FocusRequester = remember { FocusRequester() }, arrivalFocusRequester: FocusRequester = remember { FocusRequester() }, onRouteInputChanged: (String) -> Unit, + onRouteInputChangedByType: (RouterType, String) -> Unit = { _, value -> onRouteInputChanged(value) }, onRouteInputFocused: (RouterType) -> Unit, onPlaceSelected: (AddressInfo) -> Unit, onHistorySelected: (PlaceHistory) -> Unit, @@ -137,6 +138,7 @@ fun PlacePickerScreen( departureFocusRequester = departureFocusRequester, arrivalFocusRequester = arrivalFocusRequester, onRouteInputChanged = onRouteInputChanged, + onRouteInputChangedByType = onRouteInputChangedByType, onRouteInputFocused = onRouteInputFocused, ) diff --git a/core/util/src/commonMain/kotlin/com/ondot/util/DefaultScheduleAlarmManager.kt b/core/util/src/commonMain/kotlin/com/ondot/util/DefaultScheduleAlarmManager.kt index 39d29dc6..acababe6 100644 --- a/core/util/src/commonMain/kotlin/com/ondot/util/DefaultScheduleAlarmManager.kt +++ b/core/util/src/commonMain/kotlin/com/ondot/util/DefaultScheduleAlarmManager.kt @@ -28,7 +28,10 @@ class DefaultScheduleAlarmManager( val mapProvider = currentMapProvider() val dedupedAlarmInfos = dedupeAlarmInfos( - schedules.flatMap { schedule -> enabledAlarmInfos(schedule) }, + validAlarmInfos( + infos = schedules.flatMap { schedule -> enabledAlarmInfos(schedule) }, + throwOnInvalid = false, + ), ) val currentAlarmIds = dedupedAlarmInfos.map { it.alarm.alarmId }.toSet() @@ -59,7 +62,13 @@ class DefaultScheduleAlarmManager( override suspend fun schedule(schedule: Schedule) { val mapProvider = currentMapProvider() - val dedupedAlarmInfos = dedupeAlarmInfos(enabledAlarmInfos(schedule)) + val dedupedAlarmInfos = + dedupeAlarmInfos( + validAlarmInfos( + infos = enabledAlarmInfos(schedule), + throwOnInvalid = true, + ), + ) scheduleInfos( infos = dedupedAlarmInfos, @@ -189,6 +198,31 @@ class DefaultScheduleAlarmManager( private suspend fun currentMapProvider(): MapProvider = memberRepository.getLocalMapProvider().first() + private fun validAlarmInfos( + infos: List, + throwOnInvalid: Boolean, + ): List { + val invalidInfos = infos.filter { it.alarm.alarmId <= 0 } + + if (invalidInfos.isNotEmpty()) { + val message = + invalidInfos.joinToString( + prefix = "invalid alarmId detected: ", + separator = ", ", + ) { info -> + "scheduleId=${info.scheduleId}, alarmId=${info.alarm.alarmId}, type=${info.alarmType}" + } + + if (throwOnInvalid) { + throw IllegalStateException(message) + } else { + logger.e { message } + } + } + + return infos.filter { it.alarm.alarmId > 0 } + } + private fun enabledAlarmIds(schedule: Schedule): List = buildList { if (schedule.preparationAlarm.enabled) add(schedule.preparationAlarm.alarmId) diff --git a/domain/src/commonMain/kotlin/com/ondot/domain/model/request/ScheduleAlarmRequest.kt b/domain/src/commonMain/kotlin/com/ondot/domain/model/request/ScheduleAlarmRequest.kt index 3055f6b6..2290f7f1 100644 --- a/domain/src/commonMain/kotlin/com/ondot/domain/model/request/ScheduleAlarmRequest.kt +++ b/domain/src/commonMain/kotlin/com/ondot/domain/model/request/ScheduleAlarmRequest.kt @@ -8,4 +8,5 @@ data class ScheduleAlarmRequest( val startLatitude: Double, val endLongitude: Double, val endLatitude: Double, + val transportType: String, ) diff --git a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditPlacePickerRoute.kt b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditPlacePickerRoute.kt new file mode 100644 index 00000000..3608c13f --- /dev/null +++ b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditPlacePickerRoute.kt @@ -0,0 +1,83 @@ +package com.ondot.edit + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ondot.platform.util.BackPressHandler +import com.ondot.ui.screen.placepicker.PlacePickerScreen +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun EditPlacePickerRoute( + viewModel: EditScheduleViewModel = koinViewModel(), + popScreen: () -> Unit, + navigateToRouteLoading: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val departureFocusRequester = remember { FocusRequester() } + val arrivalFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + viewModel.preparePlacePicker() + + if (uiState.placePickerState.homeAddress.title + .isBlank() && + !uiState.isHomeAddressInitialized + ) { + viewModel.fetchHomeAddress() + } + viewModel.fetchPlaceHistory() + } + + LaunchedEffect(viewModel.eventFlow) { + viewModel.eventFlow.collect { event -> + when (event) { + EditScheduleEvent.RequestArrivalFocus -> arrivalFocusRequester.requestFocus() + else -> Unit + } + } + } + + LaunchedEffect(uiState.isInitialPlacePicker) { + if (uiState.isInitialPlacePicker) { + departureFocusRequester.requestFocus() + viewModel.setInitialPlacePicker(false) + } + } + + BackPressHandler(onBack = popScreen) + + PlacePickerScreen( + state = uiState.placePickerState, + buttonEnabled = uiState.isPlacePickerButtonEnabled, + departureFocusRequester = departureFocusRequester, + arrivalFocusRequester = arrivalFocusRequester, + onRouteInputChanged = viewModel::updateRouteInput, + onRouteInputChangedByType = viewModel::updateRouteInput, + onRouteInputFocused = viewModel::setFocusedRouterType, + onPlaceSelected = { + viewModel.selectPlace(it) + focusManager.clearFocus() + }, + onHistorySelected = { + viewModel.selectHistory(it) + focusManager.clearFocus() + }, + onDeleteHistory = viewModel::deletePlaceHistory, + onToggleCheckBox = viewModel::toggleHomeDeparture, + onTransportTypeChanged = viewModel::updateTransportType, + onNext = { + if (viewModel.applyRouteChangesAndFetchAlarms()) { + navigateToRouteLoading() + } else { + popScreen() + } + }, + popScreen = popScreen, + ) +} diff --git a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditRouteLoadingRoute.kt b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditRouteLoadingRoute.kt new file mode 100644 index 00000000..4259c625 --- /dev/null +++ b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditRouteLoadingRoute.kt @@ -0,0 +1,27 @@ +package com.ondot.edit + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.ondot.ui.screen.loading.RouteLoadingScreen +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun EditRouteLoadingRoute( + viewModel: EditScheduleViewModel = koinViewModel(), + navigateToEdit: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(uiState.isAlarmRecalculating) { + if (!uiState.isAlarmRecalculating) { + navigateToEdit() + } + } + + RouteLoadingScreen( + autoNavigate = false, + navigateToNext = {}, + ) +} diff --git a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleEvent.kt b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleEvent.kt index e3af3118..da2c69bb 100644 --- a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleEvent.kt +++ b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleEvent.kt @@ -4,4 +4,6 @@ import com.ondot.ui.base.Event sealed class EditScheduleEvent : Event { data object NavigateBack : EditScheduleEvent() + + data object RequestArrivalFocus : EditScheduleEvent() } diff --git a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleScreen.kt b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleScreen.kt index 8a30de21..5e2572ac 100644 --- a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleScreen.kt +++ b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleScreen.kt @@ -55,7 +55,8 @@ import com.ondot.designsystem.components.OnDotButton import com.ondot.designsystem.components.OnDotDialog import com.ondot.designsystem.components.OnDotText import com.ondot.designsystem.components.RouteInputSection -import com.ondot.designsystem.components.TopBar +import com.ondot.designsystem.components.topbar.CommonTopBar +import com.ondot.designsystem.components.topbar.model.TopBarStyle import com.ondot.designsystem.getPlatform import com.ondot.designsystem.theme.OnDotColor.GradientGreenBottom import com.ondot.designsystem.theme.OnDotColor.GradientGreenTop @@ -69,9 +70,9 @@ import com.ondot.domain.model.enums.ButtonType import com.ondot.domain.model.enums.OnDotTextStyle import com.ondot.domain.model.enums.TimeBottomSheet import com.ondot.domain.model.enums.TimeType -import com.ondot.domain.model.enums.TopBarType import com.ondot.edit.bottomSheet.EditDateBottomSheet import com.ondot.edit.bottomSheet.EditTimeBottomSheet +import com.ondot.ui.util.noRippleClickable import com.ondot.util.DateTimeFormatter.toLocalDateFromIso import com.ondot.util.DateTimeFormatter.toLocalTimeFromIso import kotlinx.coroutines.delay @@ -87,6 +88,8 @@ fun EditScheduleScreen( scheduleId: Long, viewModel: EditScheduleViewModel = koinViewModel(), popScreen: () -> Unit, + navigateToPlacePicker: () -> Unit, + navigateToRouteLoading: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val focusRequester = remember { FocusRequester() } @@ -103,6 +106,7 @@ fun EditScheduleScreen( viewModel.eventFlow.collect { event -> when (event) { is EditScheduleEvent.NavigateBack -> popScreen() + else -> Unit } } } @@ -124,6 +128,11 @@ fun EditScheduleScreen( onShowTimeBottomSheet = viewModel::showTimeBottomSheet, onDismissDateBottomSheet = viewModel::hideDateBottomSheet, onDismissTimeBottomSheet = viewModel::hideTimeBottomSheet, + onClickRouteInput = navigateToPlacePicker, + onEditAppointmentTime = { date, time -> + viewModel.editTime(date, time) + navigateToRouteLoading() + }, ) } else { Box(modifier = Modifier.fillMaxSize().background(Gray900)) @@ -147,6 +156,8 @@ fun EditScheduleContent( onShowTimeBottomSheet: (TimeType) -> Unit, onDismissDateBottomSheet: () -> Unit, onDismissTimeBottomSheet: () -> Unit, + onClickRouteInput: () -> Unit, + onEditAppointmentTime: (LocalDate, LocalTime) -> Unit, ) { val appointmentDate = uiState.schedule.appointmentAt.toLocalTimeFromIso() val scrollState = rememberScrollState() @@ -206,11 +217,18 @@ fun EditScheduleContent( Spacer(modifier = Modifier.height(16.dp)) - RouteInputSection( - departurePlaceInput = uiState.schedule.departurePlace.title, - arrivalPlaceInput = uiState.schedule.arrivalPlace.title, - readOnly = true, - ) + Box( + modifier = + Modifier + .fillMaxWidth() + .noRippleClickable { onClickRouteInput() }, + ) { + RouteInputSection( + departurePlaceInput = uiState.schedule.departurePlace.title, + arrivalPlaceInput = uiState.schedule.arrivalPlace.title, + readOnly = true, + ) + } Spacer(modifier = Modifier.height(16.dp)) } @@ -278,7 +296,9 @@ fun EditScheduleContent( isRepeat = uiState.schedule.isRepeat, repeatDays = uiState.schedule.repeatDays.toSet(), currentDate = uiState.schedule.appointmentAt.toLocalDateFromIso(), - onEditDate = onEditDate, + onEditDate = { isRepeat, repeatDays, date -> + onEditDate(isRepeat, repeatDays, date) + }, onDismiss = onDismissDateBottomSheet, ) } @@ -300,7 +320,11 @@ fun EditScheduleContent( onDismissTimeBottomSheet() }, onTimeSelected = { date, time -> - onEditTime(date, time) + if (uiState.selectedTimeType == TimeType.APPOINTMENT) { + onEditAppointmentTime(date, time) + } else { + onEditTime(date, time) + } onDismissTimeBottomSheet() }, ) @@ -346,13 +370,13 @@ private fun TopBarSection( } val focusManager = LocalFocusManager.current - TopBar( - type = TopBarType.CLOSE, + CommonTopBar( buttonColor = Gray800, + style = TopBarStyle.CloseTitleEdit, onClick = onClickClose, content = { Row( - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { BasicTextField( diff --git a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleUiState.kt b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleUiState.kt index cd59fa56..db7a908f 100644 --- a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleUiState.kt +++ b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleUiState.kt @@ -4,6 +4,7 @@ import com.ondot.domain.model.enums.TimeBottomSheet import com.ondot.domain.model.enums.TimeType import com.ondot.domain.model.schedule.ScheduleDetail import com.ondot.ui.base.UiState +import com.ondot.ui.screen.placepicker.model.PlacePickerUiModel import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone @@ -29,4 +30,13 @@ data class EditScheduleUiState val showDateBottomSheet: Boolean = false, val activeTimeBottomSheet: TimeBottomSheet? = null, val selectedTimeType: TimeType = TimeType.DEPARTURE, // 시간을 수정하는 바텀시트가 어떤 것에 의해 렌더링되었는지 저장 - ) : UiState + val placePickerState: PlacePickerUiModel = PlacePickerUiModel(), + val isInitialPlacePicker: Boolean = true, + val isHomeAddressInitialized: Boolean = false, + val isAlarmRecalculating: Boolean = false, + ) : UiState { + val isPlacePickerButtonEnabled: Boolean + get() = + placePickerState.selectedDeparturePlace != null && + placePickerState.selectedArrivalPlace != null + } diff --git a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleViewModel.kt b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleViewModel.kt index 16463a53..93cb71ff 100644 --- a/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleViewModel.kt +++ b/feature/edit/src/commonMain/kotlin/com/ondot/edit/EditScheduleViewModel.kt @@ -4,34 +4,79 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import com.dh.ondot.presentation.ui.theme.ERROR_DELETE_SCHEDULE import com.dh.ondot.presentation.ui.theme.ERROR_EDIT_SCHEDULE +import com.dh.ondot.presentation.ui.theme.ERROR_GET_HOME_ADDRESS +import com.dh.ondot.presentation.ui.theme.ERROR_GET_PLACE_HISTORY +import com.dh.ondot.presentation.ui.theme.ERROR_GET_SCHEDULE_ALARMS import com.dh.ondot.presentation.ui.theme.ERROR_GET_SCHEDULE_DETAIL +import com.dh.ondot.presentation.ui.theme.ERROR_SEARCH_PLACE import com.dh.ondot.presentation.ui.theme.ERROR_UPDATE_ALARM +import com.ondot.domain.model.enums.RouterType import com.ondot.domain.model.enums.TimeBottomSheet import com.ondot.domain.model.enums.TimeType import com.ondot.domain.model.enums.ToastType +import com.ondot.domain.model.enums.TransportType +import com.ondot.domain.model.member.AddressInfo +import com.ondot.domain.model.member.HomeAddressInfo +import com.ondot.domain.model.member.PlaceHistory +import com.ondot.domain.model.request.DeletePlaceHistoryRequest +import com.ondot.domain.model.request.ScheduleAlarmRequest import com.ondot.domain.model.schedule.Schedule +import com.ondot.domain.model.schedule.ScheduleAlarm import com.ondot.domain.model.schedule.ScheduleDetail +import com.ondot.domain.repository.MemberRepository +import com.ondot.domain.repository.PlaceRepository import com.ondot.domain.repository.ScheduleRepository import com.ondot.domain.service.ScheduleAlarmManager import com.ondot.ui.base.BaseViewModel +import com.ondot.ui.screen.placepicker.model.PlacePickerUiModel import com.ondot.ui.util.ToastManager import com.ondot.util.DateTimeFormatter -import com.ondot.util.DateTimeFormatter.toIsoTimeString import com.ondot.util.DateTimeFormatter.toLocalDateFromIso import com.ondot.util.DateTimeFormatter.toLocalTimeFromIso +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil +import kotlinx.datetime.plus import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import kotlin.time.ExperimentalTime +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) class EditScheduleViewModel( private val scheduleRepository: ScheduleRepository, + private val placeRepository: PlaceRepository, + private val memberRepository: MemberRepository, private val scheduleAlarmManager: ScheduleAlarmManager, ) : BaseViewModel(EditScheduleUiState()) { private val logger = Logger.withTag("EditScheduleViewModel") + private val query = MutableStateFlow("") + private var searchPlaceJob: Job? = null + + init { + viewModelScope.launch { + query + .debounce(100) + .distinctUntilChanged() + .onEach { value -> + if (value.isBlank()) { + searchPlaceJob?.cancel() + updatePlacePickerState { copy(placeList = emptyList()) } + } + }.filter { it.isNotBlank() } + .collect(::searchPlace) + } + } /**------------------------------------------일정 상세 조회-------------------------------------------------*/ @@ -54,6 +99,14 @@ class EditScheduleViewModel( isInitialized = true, selectedDate = date, selectedTime = time, + placePickerState = + PlacePickerUiModel( + departurePlaceInput = result.departurePlace.title, + arrivalPlaceInput = result.arrivalPlace.title, + selectedDeparturePlace = result.departurePlace, + selectedArrivalPlace = result.arrivalPlace, + selectedTransportType = result.transportType, + ), ), ) } @@ -195,6 +248,218 @@ class EditScheduleViewModel( updateState(uiState.value.copy(showDeleteDialog = false)) } + fun fetchHomeAddress() { + viewModelScope.launch { + memberRepository.getHomeAddress().collect { + resultResponse(it, ::onSuccessGetHomeAddress, ::onFailGetHomeAddress) + } + } + } + + private fun onSuccessGetHomeAddress(result: HomeAddressInfo) { + updateState( + uiState.value.copy( + placePickerState = + uiState.value.placePickerState.copy( + homeAddress = + AddressInfo( + title = result.roadAddress, + roadAddress = result.roadAddress, + latitude = result.latitude, + longitude = result.longitude, + ), + ), + isHomeAddressInitialized = true, + ), + ) + } + + private fun onFailGetHomeAddress(e: Throwable) { + logger.e { "집 주소 조회 실패: ${e.message}" } + viewModelScope.launch { ToastManager.show(ERROR_GET_HOME_ADDRESS, ToastType.ERROR) } + } + + fun fetchPlaceHistory() { + viewModelScope.launch { + placeRepository.getPlaceHistory().collect { + resultResponse(it, ::onSuccessGetPlaceHistory, ::onFailGetPlaceHistory) + } + } + } + + private fun onSuccessGetPlaceHistory(result: List) { + updatePlacePickerState { copy(placeHistory = result) } + } + + private fun onFailGetPlaceHistory(e: Throwable) { + logger.e { "장소 검색 기록 조회 실패: ${e.message}" } + viewModelScope.launch { ToastManager.show(ERROR_GET_PLACE_HISTORY, ToastType.ERROR) } + } + + fun setFocusedRouterType(type: RouterType) { + updatePlacePickerState { copy(lastFocusedTextField = type) } + } + + fun updateRouteInput(value: String) { + updateRouteInput(uiState.value.placePickerState.lastFocusedTextField, value) + } + + fun updateRouteInput( + type: RouterType, + value: String, + ) { + when (type) { + RouterType.Departure -> + updatePlacePickerState { + copy( + lastFocusedTextField = type, + isChecked = isChecked && value.isHomeAddressInput(), + departurePlaceInput = value, + selectedDeparturePlace = null, + ) + } + + RouterType.Arrival -> + updatePlacePickerState { + copy( + lastFocusedTextField = type, + arrivalPlaceInput = value, + selectedArrivalPlace = null, + ) + } + } + + query.value = value + } + + fun selectPlace(place: AddressInfo) { + savePlaceHistory(place) + + when (uiState.value.placePickerState.lastFocusedTextField) { + RouterType.Departure -> { + updatePlacePickerState { + copy( + placeList = emptyList(), + isChecked = isChecked && place.isHomeAddress(), + departurePlaceInput = place.title, + selectedDeparturePlace = place, + ) + } + emitEventFlow(EditScheduleEvent.RequestArrivalFocus) + setFocusedRouterType(RouterType.Arrival) + query.value = "" + } + + RouterType.Arrival -> + updatePlacePickerState { + copy( + placeList = emptyList(), + arrivalPlaceInput = place.title, + selectedArrivalPlace = place, + ) + } + } + } + + fun selectHistory(place: PlaceHistory) { + selectPlace( + AddressInfo( + title = place.title, + roadAddress = place.roadAddress, + latitude = place.latitude, + longitude = place.longitude, + ), + ) + } + + fun deletePlaceHistory(place: PlaceHistory) { + viewModelScope.launch { + placeRepository.deletePlaceHistory(DeletePlaceHistoryRequest(place.searchedAt)).collect { + resultResponse(it, { fetchPlaceHistory() }, ::onFailGetPlaceHistory) + } + } + } + + fun toggleHomeDeparture() { + val current = uiState.value.placePickerState + + if (!current.isChecked && current.homeAddress.title.isBlank()) { + fetchHomeAddress() + return + } + + updatePlacePickerState { + copy( + isChecked = !current.isChecked, + departurePlaceInput = if (!current.isChecked) homeAddress.roadAddress else "", + selectedDeparturePlace = if (!current.isChecked) homeAddress else null, + ) + } + } + + fun updateTransportType(type: TransportType) { + updatePlacePickerState { copy(selectedTransportType = type) } + } + + fun setInitialPlacePicker(value: Boolean) { + updateState(uiState.value.copy(isInitialPlacePicker = value)) + } + + fun preparePlacePicker() { + val current = uiState.value + val currentSchedule = current.schedule + val currentPlacePickerState = current.placePickerState + val isHomeDeparture = + currentPlacePickerState.homeAddress.title.isNotBlank() && + currentSchedule.departurePlace.isHomeAddress() + + updateStateSync( + current.copy( + placePickerState = + currentPlacePickerState.copy( + isChecked = isHomeDeparture, + departurePlaceInput = currentSchedule.departurePlace.title, + arrivalPlaceInput = currentSchedule.arrivalPlace.title, + selectedDeparturePlace = currentSchedule.departurePlace, + selectedArrivalPlace = currentSchedule.arrivalPlace, + placeList = emptyList(), + lastFocusedTextField = RouterType.Departure, + selectedTransportType = currentSchedule.transportType, + ), + isInitialPlacePicker = true, + ), + ) + query.value = "" + } + + fun applyRouteChangesAndFetchAlarms(): Boolean { + val current = uiState.value + val departurePlace = current.placePickerState.selectedDeparturePlace ?: return false + val arrivalPlace = current.placePickerState.selectedArrivalPlace ?: return false + val transportType = current.placePickerState.selectedTransportType + val hasRouteChanged = + current.schedule.departurePlace != departurePlace || + current.schedule.arrivalPlace != arrivalPlace || + current.schedule.transportType != transportType + + updateStateSync( + current.copy( + schedule = + current.schedule.copy( + departurePlace = departurePlace, + arrivalPlace = arrivalPlace, + transportType = transportType, + ), + ), + ) + + if (hasRouteChanged) { + fetchScheduleAlarms() + } + + return hasRouteChanged + } + fun editDate( isRepeat: Boolean, repeatDays: Set, @@ -204,10 +469,18 @@ class EditScheduleViewModel( if (isRepeat && repeatDays.isEmpty()) return - updateState( - uiState.value.copy( + val current = uiState.value + val nextSchedule = + if (!isRepeat && date != null) { + current.schedule.shiftDate(date) + } else { + current.schedule + } + + updateStateSync( + current.copy( schedule = - uiState.value.schedule.copy( + nextSchedule.copy( isRepeat = isRepeat, repeatDays = repeatDays.toList(), ), @@ -229,12 +502,21 @@ class EditScheduleViewModel( newTime: LocalTime, ) { when (uiState.value.selectedTimeType) { - TimeType.APPOINTMENT -> - updateState( + TimeType.APPOINTMENT -> { + updateStateSync( uiState.value.copy( - schedule = uiState.value.schedule.copy(appointmentAt = newTime.toIsoTimeString()), + schedule = + uiState.value.schedule.copy( + appointmentAt = + DateTimeFormatter.formatIsoDateTime( + date = resolveAppointmentDate(), + time = newTime, + ), + ), ), ) + fetchScheduleAlarms() + } TimeType.DEPARTURE -> updateState( uiState.value.copy( @@ -318,6 +600,158 @@ class EditScheduleViewModel( updateState(uiState.value.copy(activeTimeBottomSheet = null)) } + private fun searchPlace(query: String) { + searchPlaceJob?.cancel() + searchPlaceJob = + viewModelScope.launch { + placeRepository.searchPlace(query).collect { + resultResponse(it, ::onSuccessSearchPlace, ::onFailSearchPlace) + } + } + } + + private fun onSuccessSearchPlace(result: List) { + updatePlacePickerState { copy(placeList = result) } + } + + private fun onFailSearchPlace(e: Throwable) { + logger.e { "장소 검색 실패: ${e.message}" } + viewModelScope.launch { ToastManager.show(ERROR_SEARCH_PLACE, ToastType.ERROR) } + } + + private fun savePlaceHistory(place: AddressInfo) { + viewModelScope.launch { + placeRepository.savePlaceHistory(place).collect { + resultResponse(it, { fetchPlaceHistory() }) + } + } + } + + @OptIn(ExperimentalTime::class) + private fun fetchScheduleAlarms() { + val current = uiState.value + val departurePlace = current.schedule.departurePlace + val arrivalPlace = current.schedule.arrivalPlace + + if (departurePlace.title.isBlank() || arrivalPlace.title.isBlank()) return + + updateStateSync(current.copy(isAlarmRecalculating = true)) + + val zone = TimeZone.currentSystemDefault() + val today = + Clock.System + .now() + .toLocalDateTime(zone) + .date + val selectedDate = current.selectedDate ?: today + val appointmentAt = + DateTimeFormatter.formatIsoDateTime( + date = selectedDate, + time = current.schedule.appointmentAt.toLocalTimeFromIso(), + ) + + viewModelScope.launch { + scheduleRepository + .getScheduleAlarms( + ScheduleAlarmRequest( + appointmentAt = appointmentAt, + startLatitude = departurePlace.latitude, + startLongitude = departurePlace.longitude, + endLatitude = arrivalPlace.latitude, + endLongitude = arrivalPlace.longitude, + transportType = current.schedule.transportType.name, + ), + ).collect { + resultResponse(it, ::onSuccessGetScheduleAlarms, ::onFailGetScheduleAlarms) + } + } + } + + private fun onSuccessGetScheduleAlarms(result: ScheduleAlarm) { + val current = uiState.value + val originalSchedule = current.originalSchedule ?: current.schedule + + updateStateSync( + current.copy( + schedule = + current.schedule.copy( + preparationAlarm = + result.preparationAlarm.copy( + alarmId = originalSchedule.preparationAlarm.alarmId, + enabled = current.schedule.preparationAlarm.enabled, + ), + departureAlarm = + result.departureAlarm.copy( + alarmId = originalSchedule.departureAlarm.alarmId, + enabled = current.schedule.departureAlarm.enabled, + ), + ), + isAlarmRecalculating = false, + ), + ) + } + + private fun onFailGetScheduleAlarms(e: Throwable) { + updateStateSync(uiState.value.copy(isAlarmRecalculating = false)) + logger.e { "알람 정보 계산 실패: ${e.message}" } + viewModelScope.launch { ToastManager.show(ERROR_GET_SCHEDULE_ALARMS, ToastType.ERROR) } + } + + @OptIn(ExperimentalTime::class) + private fun resolveAppointmentDate(): LocalDate { + uiState.value.selectedDate?.let { return it } + + return runCatching { + uiState.value.schedule.appointmentAt + .toLocalDateFromIso() + }.getOrElse { + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + } + } + + private fun updatePlacePickerState(block: PlacePickerUiModel.() -> PlacePickerUiModel) { + updateStateSync(uiState.value.copy(placePickerState = uiState.value.placePickerState.block())) + } + + private fun ScheduleDetail.shiftDate(newAppointmentDate: LocalDate): ScheduleDetail { + val currentAppointmentDate = + runCatching { + appointmentAt.toLocalDateFromIso() + }.getOrNull() ?: return this + val deltaDays = currentAppointmentDate.daysUntil(newAppointmentDate) + + if (deltaDays == 0) return this + + return copy( + appointmentAt = appointmentAt.shiftDateBy(deltaDays), + preparationAlarm = preparationAlarm.copy(triggeredAt = preparationAlarm.triggeredAt.shiftDateBy(deltaDays)), + departureAlarm = departureAlarm.copy(triggeredAt = departureAlarm.triggeredAt.shiftDateBy(deltaDays)), + ) + } + + private fun String.shiftDateBy(deltaDays: Int): String = + runCatching { + DateTimeFormatter.formatIsoDateTime( + date = toLocalDateFromIso().plus(DatePeriod(days = deltaDays)), + time = toLocalTimeFromIso(), + ) + }.getOrDefault(this) + + private fun String.isHomeAddressInput(): Boolean { + val homeAddress = uiState.value.placePickerState.homeAddress + return this == homeAddress.roadAddress || this == homeAddress.title + } + + private fun AddressInfo.isHomeAddress(): Boolean { + val homeAddress = uiState.value.placePickerState.homeAddress + return roadAddress == homeAddress.roadAddress && + latitude == homeAddress.latitude && + longitude == homeAddress.longitude + } + private fun ScheduleDetail.toSchedule(scheduleId: Long): Schedule = Schedule( scheduleId = scheduleId, diff --git a/feature/edit/src/commonMain/kotlin/com/ondot/edit/navigation/EditScheduleNavGraph.kt b/feature/edit/src/commonMain/kotlin/com/ondot/edit/navigation/EditScheduleNavGraph.kt index 0bc8e926..b07014df 100644 --- a/feature/edit/src/commonMain/kotlin/com/ondot/edit/navigation/EditScheduleNavGraph.kt +++ b/feature/edit/src/commonMain/kotlin/com/ondot/edit/navigation/EditScheduleNavGraph.kt @@ -1,5 +1,6 @@ package com.ondot.edit.navigation +import androidx.compose.runtime.remember import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -7,9 +8,13 @@ import androidx.navigation.compose.composable import androidx.navigation.navArgument import androidx.navigation.navigation import androidx.navigation.toRoute +import com.ondot.edit.EditPlacePickerRoute +import com.ondot.edit.EditRouteLoadingRoute import com.ondot.edit.EditScheduleScreen +import com.ondot.edit.EditScheduleViewModel import com.ondot.navigation.NavRoutes import com.ondot.navigation.base.NavGraphContributor +import org.koin.compose.viewmodel.koinViewModel object EditScheduleNavGraph : NavGraphContributor { override val graphRoute: NavRoutes @@ -26,12 +31,61 @@ object EditScheduleNavGraph : NavGraphContributor { NavRoutes.EditSchedule.ROUTE, arguments = listOf(navArgument("scheduleId") { type = NavType.LongType }), ) { backStackEntry -> + val parentEntry = + remember(backStackEntry) { + navController.getBackStackEntry(graphRoute.route) + } + val viewModel: EditScheduleViewModel = koinViewModel(viewModelStoreOwner = parentEntry) val args = backStackEntry.toRoute() val scheduleId = args.scheduleId EditScheduleScreen( scheduleId = scheduleId, + viewModel = viewModel, popScreen = { navController.popBackStack() }, + navigateToPlacePicker = { + navController.navigate(NavRoutes.EditPlacePicker.route) { + launchSingleTop = true + } + }, + navigateToRouteLoading = { + navController.navigate(NavRoutes.EditRouteLoading.route) { + launchSingleTop = true + } + }, + ) + } + + composable(NavRoutes.EditPlacePicker.route) { backStackEntry -> + val parentEntry = + remember(backStackEntry) { + navController.getBackStackEntry(graphRoute.route) + } + val viewModel: EditScheduleViewModel = koinViewModel(viewModelStoreOwner = parentEntry) + + EditPlacePickerRoute( + viewModel = viewModel, + popScreen = { navController.popBackStack() }, + navigateToRouteLoading = { + navController.navigate(NavRoutes.EditRouteLoading.route) { + launchSingleTop = true + } + }, + ) + } + + composable(NavRoutes.EditRouteLoading.route) { backStackEntry -> + val parentEntry = + remember(backStackEntry) { + navController.getBackStackEntry(graphRoute.route) + } + val viewModel: EditScheduleViewModel = koinViewModel(viewModelStoreOwner = parentEntry) + + EditRouteLoadingRoute( + viewModel = viewModel, + navigateToEdit = { + navController.popBackStack(NavRoutes.EditSchedule.ROUTE, inclusive = false) + }, ) } } diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleUiState.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleUiState.kt index c7d159c3..156ee940 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleUiState.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleUiState.kt @@ -2,6 +2,7 @@ package com.ondot.general import com.dh.ondot.presentation.ui.theme.NEW_SCHEDULE_LABEL import com.ondot.domain.model.alarm.Alarm +import com.ondot.domain.model.enums.TimeType import com.ondot.ui.base.UiState import com.ondot.ui.screen.placepicker.model.PlacePickerUiModel import com.ondot.util.DateTimeFormatter @@ -52,6 +53,7 @@ data class GeneralScheduleUiState // CheckSchedule val scheduleTitle: String = NEW_SCHEDULE_LABEL, val showBottomSheet: Boolean = false, + val activeAlarmTimeBottomSheet: TimeType? = null, ) : UiState { companion object { fun formattedDate(date: String) = DateTimeFormatter.formatKoreanDateMonthDay(date) diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleViewModel.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleViewModel.kt index 9824281c..56fd64ca 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleViewModel.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/GeneralScheduleViewModel.kt @@ -209,17 +209,29 @@ class GeneralScheduleViewModel( } fun onRouteInputChanged(value: String) { - onInputValueChanged(value) + onInputValueChanged(uiState.value.placePickerState.lastFocusedTextField, value) query.value = value } - private fun onInputValueChanged(value: String) { - when (uiState.value.placePickerState.lastFocusedTextField) { + fun onRouteInputChanged( + type: RouterType, + value: String, + ) { + onInputValueChanged(type, value) + query.value = value + } + + private fun onInputValueChanged( + type: RouterType, + value: String, + ) { + when (type) { RouterType.Departure -> updateState( uiState.value.copy( placePickerState = uiState.value.placePickerState.copy( + lastFocusedTextField = type, isChecked = uiState.value.placePickerState.isChecked && value.isHomeAddressInput(), departurePlaceInput = value, selectedDeparturePlace = null, @@ -231,6 +243,7 @@ class GeneralScheduleViewModel( uiState.value.copy( placePickerState = uiState.value.placePickerState.copy( + lastFocusedTextField = type, arrivalPlaceInput = value, selectedArrivalPlace = null, ), @@ -413,6 +426,7 @@ class GeneralScheduleViewModel( startLongitude = departurePlace.longitude, endLatitude = arrivalPlace.latitude, endLongitude = arrivalPlace.longitude, + transportType = TransportType.PUBLIC_TRANSPORT.name, ), ).collect { resultResponse(it, ::onSuccessGetScheduleAlarms, ::onFailedGetScheduleAlarms) diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/check/CheckScheduleScreen.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/check/CheckScheduleScreen.kt index 7498ec76..2ef15c26 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/check/CheckScheduleScreen.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/check/CheckScheduleScreen.kt @@ -62,12 +62,17 @@ import com.ondot.designsystem.theme.OnDotColor.Gray900 import com.ondot.domain.model.enums.AlarmType import com.ondot.domain.model.enums.ButtonType import com.ondot.domain.model.enums.OnDotTextStyle +import com.ondot.domain.model.enums.TimeType import com.ondot.domain.model.enums.TopBarType import com.ondot.general.GeneralScheduleEvent import com.ondot.general.GeneralScheduleUiState import com.ondot.general.GeneralScheduleViewModel import com.ondot.util.DateTimeFormatter.toIsoDateString +import com.ondot.util.DateTimeFormatter.toLocalDateFromIso +import com.ondot.util.DateTimeFormatter.toLocalTimeFromIso import com.ondot.util.platform +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime import ondot.core.design_system.generated.resources.Res import ondot.core.design_system.generated.resources.ic_pencil_white import org.jetbrains.compose.resources.painterResource @@ -98,6 +103,9 @@ fun CheckScheduleScreen( onToggleSwitch = viewModel::updatePreparationAlarmEnabled, onShowBottomSheet = { viewModel.updateBottomSheetVisible(true) }, onDismiss = { viewModel.updateBottomSheetVisible(false) }, + onShowAlarmTimeBottomSheet = {}, + onDismissAlarmTimeBottomSheet = {}, + onEditAlarmTime = { _, _, _ -> }, ) } @@ -111,6 +119,9 @@ fun CheckScheduleContent( onToggleSwitch: () -> Unit, onShowBottomSheet: () -> Unit, onDismiss: () -> Unit, + onShowAlarmTimeBottomSheet: (TimeType) -> Unit, + onDismissAlarmTimeBottomSheet: () -> Unit, + onEditAlarmTime: (TimeType, LocalDate, LocalTime) -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { Column( @@ -166,6 +177,7 @@ fun CheckScheduleContent( info = uiState.preparationAlarm, type = AlarmType.Preparation, scheduleDate = uiState.selectedDate?.toIsoDateString() ?: "", + onClick = { onShowAlarmTimeBottomSheet(TimeType.PREPARATION) }, onToggleSwitch = onToggleSwitch, ) @@ -175,6 +187,7 @@ fun CheckScheduleContent( info = uiState.departureAlarm, type = AlarmType.Departure, scheduleDate = uiState.selectedDate?.toIsoDateString() ?: "", + onClick = { onShowAlarmTimeBottomSheet(TimeType.DEPARTURE) }, ) Spacer(modifier = Modifier.weight(1f)) @@ -201,6 +214,31 @@ fun CheckScheduleContent( ) } } + + uiState.activeAlarmTimeBottomSheet?.let { type -> + AnimatedVisibility( + visible = true, + modifier = Modifier.fillMaxSize(), + enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(), + exit = slideOutVertically { fullHeight -> -fullHeight } + fadeOut(), + ) { + val alarm = + when (type) { + TimeType.PREPARATION -> uiState.preparationAlarm + TimeType.DEPARTURE -> uiState.departureAlarm + TimeType.APPOINTMENT -> uiState.departureAlarm + } + + GeneralAlarmTimeBottomSheet( + currentTime = alarm.triggeredAt.toLocalTimeFromIso(), + currentDate = alarm.triggeredAt.toLocalDateFromIso(), + onDismiss = onDismissAlarmTimeBottomSheet, + onTimeSelected = { date, time -> + onEditAlarmTime(type, date, time) + }, + ) + } + } } } diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/check/GeneralAlarmTimeBottomSheet.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/check/GeneralAlarmTimeBottomSheet.kt new file mode 100644 index 00000000..fedb5ce3 --- /dev/null +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/check/GeneralAlarmTimeBottomSheet.kt @@ -0,0 +1,99 @@ +package com.ondot.general.check + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.dh.ondot.presentation.ui.theme.OnDotTheme +import com.dh.ondot.presentation.ui.theme.WORD_COMPETE +import com.ondot.designsystem.components.OnDotBottomSheet +import com.ondot.designsystem.components.OnDotButton +import com.ondot.designsystem.components.TimePicker +import com.ondot.designsystem.components.TimeSectionHeader +import com.ondot.designsystem.theme.OnDotColor.Gray600 +import com.ondot.domain.model.enums.ButtonType +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +fun GeneralAlarmTimeBottomSheet( + currentTime: LocalTime, + currentDate: LocalDate, + onDismiss: () -> Unit, + onTimeSelected: (LocalDate, LocalTime) -> Unit, +) { + var selectedTime by remember(currentTime) { mutableStateOf(currentTime) } + var selectedDate by remember(currentDate) { mutableStateOf(currentDate) } + + val periodState = rememberLazyListState(initialFirstVisibleItemIndex = 0) + val hourState = rememberLazyListState(initialFirstVisibleItemIndex = currentTime.hour.coerceIn(0, 23)) + val minuteState = rememberLazyListState(initialFirstVisibleItemIndex = currentTime.minute.coerceIn(0, 59)) + + OnDotBottomSheet( + onDismiss = onDismiss, + contentPaddingTop = 32.dp, + contentPaddingBottom = 16.dp, + content = { + Column( + modifier = + Modifier + .fillMaxWidth(), + ) { + TimeSectionHeader( + selectedTime = selectedTime, + isActiveDial = true, + onToggleDial = {}, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 4.dp), thickness = (0.5).dp, color = Gray600) + + TimePicker( + periodState = periodState, + hourState = hourState, + minuteState = minuteState, + onTimeSelected = { selectedTime = it }, + ) + + Spacer(modifier = Modifier.height(26.dp)) + + OnDotButton( + buttonText = WORD_COMPETE, + buttonType = ButtonType.Green500, + onClick = { + onTimeSelected(selectedDate, selectedTime) + }, + ) + + Spacer(Modifier.height(12.dp)) + } + }, + ) +} + +@Preview +@Composable +private fun Preview() { + OnDotTheme { + GeneralAlarmTimeBottomSheet( + currentTime = LocalTime(12, 0), + currentDate = LocalDate(2023, 1, 1), + onDismiss = {}, + onTimeSelected = { _, _ -> }, + ) + } +} diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleIntent.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleIntent.kt index 7c054d47..18bacdfa 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleIntent.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleIntent.kt @@ -1,6 +1,7 @@ package com.ondot.general.contract import com.ondot.domain.model.enums.RouterType +import com.ondot.domain.model.enums.TimeType import com.ondot.domain.model.enums.TransportType import com.ondot.domain.model.member.AddressInfo import com.ondot.domain.model.member.PlaceHistory @@ -51,6 +52,11 @@ sealed interface GeneralScheduleIntent : Intent { val input: String, ) : GeneralScheduleIntent + data class UpdateRouteInputByType( + val type: RouterType, + val input: String, + ) : GeneralScheduleIntent + data class SelectPlace( val place: AddressInfo, ) : GeneralScheduleIntent @@ -83,6 +89,16 @@ sealed interface GeneralScheduleIntent : Intent { val visible: Boolean, ) : GeneralScheduleIntent + data class SetAlarmTimeBottomSheet( + val type: TimeType?, + ) : GeneralScheduleIntent + + data class UpdateAlarmTime( + val type: TimeType, + val date: LocalDate, + val time: LocalTime, + ) : GeneralScheduleIntent + data class CreateSchedule( val isMedicationRequired: Boolean, val preparationNote: String, diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleState.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleState.kt index 3a3bfb91..fc444a6d 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleState.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleState.kt @@ -3,6 +3,7 @@ package com.ondot.general.contract import androidx.compose.runtime.Immutable import com.dh.ondot.presentation.ui.theme.NEW_SCHEDULE_LABEL import com.ondot.domain.model.alarm.Alarm +import com.ondot.domain.model.enums.TimeType import com.ondot.general.GeneralScheduleUiState import com.ondot.ui.base.UiState import com.ondot.ui.screen.placepicker.model.PlacePickerUiModel @@ -41,6 +42,7 @@ data class GeneralScheduleState( val departureAlarm: Alarm = Alarm(), val scheduleTitle: String = NEW_SCHEDULE_LABEL, val showBottomSheet: Boolean = false, + val activeAlarmTimeBottomSheet: TimeType? = null, ) : UiState { val isRepeatStepButtonEnabled: Boolean get() = selectedTime != null && (selectedDate != null || activeWeekDays.isNotEmpty()) @@ -73,4 +75,5 @@ fun GeneralScheduleState.toLegacyUiState(): GeneralScheduleUiState = departureAlarm = departureAlarm, scheduleTitle = scheduleTitle, showBottomSheet = showBottomSheet, + activeAlarmTimeBottomSheet = activeAlarmTimeBottomSheet, ) diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleViewModel.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleViewModel.kt index 45d2ef2b..2bfec263 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleViewModel.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/contract/GeneralScheduleViewModel.kt @@ -7,6 +7,7 @@ import com.dh.ondot.presentation.ui.theme.ERROR_GET_PLACE_HISTORY import com.dh.ondot.presentation.ui.theme.ERROR_GET_SCHEDULE_ALARMS import com.dh.ondot.presentation.ui.theme.ERROR_SEARCH_PLACE import com.ondot.domain.model.enums.RouterType +import com.ondot.domain.model.enums.TimeType import com.ondot.domain.model.enums.ToastType import com.ondot.domain.model.enums.TransportType import com.ondot.domain.model.member.AddressInfo @@ -87,6 +88,7 @@ class GeneralScheduleViewModel( GeneralScheduleIntent.InitPlaceHistory -> fetchPlaceHistory() is GeneralScheduleIntent.SetFocusedRouterType -> setFocusedRouterType(intent.type) is GeneralScheduleIntent.UpdateRouteInput -> updateRouteInput(intent.input) + is GeneralScheduleIntent.UpdateRouteInputByType -> updateRouteInput(intent.type, intent.input) is GeneralScheduleIntent.SelectPlace -> selectPlace(intent.place) is GeneralScheduleIntent.SelectHistory -> selectHistory(intent.history) is GeneralScheduleIntent.DeleteHistory -> deleteHistory(intent.history) @@ -97,6 +99,8 @@ class GeneralScheduleViewModel( is GeneralScheduleIntent.UpdateScheduleTitle -> updateScheduleTitle(intent.title) GeneralScheduleIntent.TogglePreparationAlarm -> togglePreparationAlarm() is GeneralScheduleIntent.SetBottomSheetVisible -> setBottomSheetVisible(intent.visible) + is GeneralScheduleIntent.SetAlarmTimeBottomSheet -> setAlarmTimeBottomSheet(intent.type) + is GeneralScheduleIntent.UpdateAlarmTime -> updateAlarmTime(intent.type, intent.date, intent.time) is GeneralScheduleIntent.CreateSchedule -> createSchedule(intent.isMedicationRequired, intent.preparationNote) is GeneralScheduleIntent.UpdateTransportType -> setTransportType(intent.type) } @@ -237,12 +241,20 @@ class GeneralScheduleViewModel( } private fun updateRouteInput(value: String) { - when (currentState.placePickerState.lastFocusedTextField) { + updateRouteInput(currentState.placePickerState.lastFocusedTextField, value) + } + + private fun updateRouteInput( + type: RouterType, + value: String, + ) { + when (type) { RouterType.Departure -> reduce { copy( placePickerState = placePickerState.copy( + lastFocusedTextField = type, isChecked = placePickerState.isChecked && value.isHomeAddressInput(), departurePlaceInput = value, selectedDeparturePlace = null, @@ -255,6 +267,7 @@ class GeneralScheduleViewModel( copy( placePickerState = placePickerState.copy( + lastFocusedTextField = type, arrivalPlaceInput = value, selectedArrivalPlace = null, ), @@ -399,6 +412,7 @@ class GeneralScheduleViewModel( startLongitude = departurePlace.longitude, endLatitude = arrivalPlace.latitude, endLongitude = arrivalPlace.longitude, + transportType = currentState.placePickerState.selectedTransportType.name, ), ) }, @@ -472,6 +486,38 @@ class GeneralScheduleViewModel( reduce { copy(showBottomSheet = visible) } } + private fun setAlarmTimeBottomSheet(type: TimeType?) { + reduce { copy(activeAlarmTimeBottomSheet = type) } + } + + private fun updateAlarmTime( + type: TimeType, + date: LocalDate, + time: LocalTime, + ) { + val triggeredAt = DateTimeFormatter.formatIsoDateTime(date, time) + + when (type) { + TimeType.PREPARATION -> + reduce { + copy( + preparationAlarm = preparationAlarm.copy(triggeredAt = triggeredAt), + activeAlarmTimeBottomSheet = null, + ) + } + + TimeType.DEPARTURE -> + reduce { + copy( + departureAlarm = departureAlarm.copy(triggeredAt = triggeredAt), + activeAlarmTimeBottomSheet = null, + ) + } + + TimeType.APPOINTMENT -> Unit + } + } + private fun setTransportType(type: TransportType) { reduce { copy( diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/place/PlacePickerScreen.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/place/PlacePickerScreen.kt index bf0d4c78..5ca567d1 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/place/PlacePickerScreen.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/place/PlacePickerScreen.kt @@ -67,6 +67,7 @@ fun PlacePickerRoute( departureFocusRequester = departureFocusRequester, arrivalFocusRequester = arrivalFocusRequester, onRouteInputChanged = viewModel::onRouteInputChanged, + onRouteInputChangedByType = viewModel::onRouteInputChanged, onRouteInputFocused = viewModel::onRouteInputFieldFocused, onPlaceSelected = { viewModel.onPlaceSelected(it) diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/ui/check/CheckScheduleRoute.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/ui/check/CheckScheduleRoute.kt index 2e46b08e..6acceea8 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/ui/check/CheckScheduleRoute.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/ui/check/CheckScheduleRoute.kt @@ -42,5 +42,10 @@ fun CheckScheduleRoute( onToggleSwitch = { viewModel.dispatch(GeneralScheduleIntent.TogglePreparationAlarm) }, onShowBottomSheet = { viewModel.dispatch(GeneralScheduleIntent.SetBottomSheetVisible(true)) }, onDismiss = { viewModel.dispatch(GeneralScheduleIntent.SetBottomSheetVisible(false)) }, + onShowAlarmTimeBottomSheet = { viewModel.dispatch(GeneralScheduleIntent.SetAlarmTimeBottomSheet(it)) }, + onDismissAlarmTimeBottomSheet = { viewModel.dispatch(GeneralScheduleIntent.SetAlarmTimeBottomSheet(null)) }, + onEditAlarmTime = { type, date, time -> + viewModel.dispatch(GeneralScheduleIntent.UpdateAlarmTime(type, date, time)) + }, ) } diff --git a/feature/general/src/commonMain/kotlin/com/ondot/general/ui/place/PlacePickerRoute.kt b/feature/general/src/commonMain/kotlin/com/ondot/general/ui/place/PlacePickerRoute.kt index c06ed56b..ceb1bb7e 100644 --- a/feature/general/src/commonMain/kotlin/com/ondot/general/ui/place/PlacePickerRoute.kt +++ b/feature/general/src/commonMain/kotlin/com/ondot/general/ui/place/PlacePickerRoute.kt @@ -68,6 +68,9 @@ fun PlacePickerRoute( departureFocusRequester = departureFocusRequester, arrivalFocusRequester = arrivalFocusRequester, onRouteInputChanged = { viewModel.dispatch(GeneralScheduleIntent.UpdateRouteInput(it)) }, + onRouteInputChangedByType = { type, input -> + viewModel.dispatch(GeneralScheduleIntent.UpdateRouteInputByType(type, input)) + }, onRouteInputFocused = { viewModel.dispatch(GeneralScheduleIntent.SetFocusedRouterType(it)) }, onPlaceSelected = { viewModel.dispatch(GeneralScheduleIntent.SelectPlace(it)) diff --git a/gradle.properties b/gradle.properties index ac6f9ef9..add43060 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,5 +21,5 @@ target.sdk=36 compile.sdk=36 # Version -version.code=39 +version.code=41 version.name=1.4.0 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec7ceabd..35cfa729 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -148,6 +148,7 @@ compose-ui = { group = "org.jetbrains.compose.ui", compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "composeMultiplatform" } compose-uiTooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling", version.ref = "composeMultiplatform" } compose-ui-tooling-preview = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview", version.ref = "composeMultiplatform" } +compose-components-ui-tooling-preview = { group = "org.jetbrains.compose.components", name = "components-ui-tooling-preview", version.ref = "composeMultiplatform" } compose-components-resources = { group = "org.jetbrains.compose.components", name = "components-resources", version.ref = "resources" } lifecycleViewModel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" } @@ -242,4 +243,3 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coro ktlint-gradle = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" } - diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index d13a4452..4885cf56 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -468,7 +468,7 @@ CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -520,7 +520,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 41; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = Y2Q4TFA96S;