From 6f82bb79d8514e1e4e01daf11fd636d324946dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Santos?= Date: Tue, 3 Jun 2025 21:37:56 -0300 Subject: [PATCH] Fix timezone change handling --- .../presentation/AppointmentScreenModel.kt | 41 +++++++++++--- .../mapper/AppointmentPresentationMapper.kt | 6 +- .../AppointmentConfirmationSection.kt | 2 + .../screens/time/AppointmentTimeScreen.kt | 3 + .../time/AppointmentTimeSelectionSection.kt | 9 +-- .../ui/components/shared/TimezoneSelector.kt | 55 +++++++++++++++++++ .../AppointmentScreenModelTest.kt | 14 +++++ 7 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/ui/components/shared/TimezoneSelector.kt diff --git a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModel.kt b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModel.kt index 1f647d4..80b7437 100644 --- a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModel.kt @@ -1,5 +1,6 @@ package br.com.cauezito.schedrix.presentation +import br.com.cauezito.schedrix.domain.model.Appointment import br.com.cauezito.schedrix.domain.useCase.GetAvailableAppointmentTimesUseCase import br.com.cauezito.schedrix.extensions.DateExtensions import br.com.cauezito.schedrix.extensions.DateExtensions.availableTimesFromSelectedDate @@ -34,15 +35,17 @@ class AppointmentScreenModel( val state: StateFlow = _state private val todayDate = DateExtensions.getCurrentDate().date - private val currentTimeZone = TimeZone.currentSystemDefault().toString().formatTimezone() + private var selectedTimeZone: TimeZone = TimeZone.currentSystemDefault() + private val currentTimeZoneFormatted = selectedTimeZone.toString().formatTimezone() private var appointments: List = emptyList() - private var choseStartAndEndDates: Pair = Pair("", "") + private var appointmentDomain: Appointment? = null + private var choseStartAndEndDates: Pair = "" to "" init { choseStartAndEndDates = todayDate.formatStartAndEnd() _state.value = _state.value.copy( currentMonthYear = todayDate, - currentTimezone = currentTimeZone + currentTimezone = currentTimeZoneFormatted ) } @@ -50,13 +53,14 @@ class AppointmentScreenModel( try { val monthPlaceHolder = _state.value.currentMonthYear.month.number.defineMonthPlaceholder() - val result = getAvailableTimes( + val domainAppointment = getAvailableTimes( choseStartAndEndDates.first, choseStartAndEndDates.second, monthPlaceHolder - ).asPresentation() + ) - appointments = result.availableAppointments + appointmentDomain = domainAppointment + appointments = domainAppointment.asPresentation(selectedTimeZone).availableAppointments _state.value = _state.value.copy( calendarDays = mapToAppointmentCalendarDay( @@ -115,6 +119,29 @@ class AppointmentScreenModel( ) } + internal fun changeTimezone(timezoneId: String) { + selectedTimeZone = TimeZone.of(timezoneId) + _state.value = _state.value.copy( + currentTimezone = timezoneId.formatTimezone() + ) + + appointmentDomain?.let { domainAppointment -> + appointments = domainAppointment.asPresentation(selectedTimeZone).availableAppointments + _state.value.selectedDate?.let { date -> + val availableTimes = date.availableTimesFromSelectedDate(appointments) + _state.value = _state.value.copy(selectedDateTimes = availableTimes) + } + + _state.value = _state.value.copy( + calendarDays = mapToAppointmentCalendarDay( + currentMonth = _state.value.currentMonthYear, + appointments = appointments, + selectedDate = _state.value.selectedDate + ) + ) + } + } + internal fun selectAppointmentTime(dateTime: AppointmentDateTime) { _state.value = _state.value.copy( isNameValid = null, @@ -127,7 +154,7 @@ class AppointmentScreenModel( internal fun tryAgainAfterError() { _state.value = _state.value.copy( currentMonthYear = todayDate, - currentTimezone = currentTimeZone, + currentTimezone = selectedTimeZone.toString().formatTimezone(), showContentLoading = true, isNameValid = null, isEmailValid = null, diff --git a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/mapper/AppointmentPresentationMapper.kt b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/mapper/AppointmentPresentationMapper.kt index 8c7181f..4987749 100644 --- a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/mapper/AppointmentPresentationMapper.kt +++ b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/mapper/AppointmentPresentationMapper.kt @@ -7,10 +7,10 @@ import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime -fun Appointment.asPresentation(): AppointmentPresentation { - val availableAppointments = this.availableTimes.map { date -> +fun Appointment.asPresentation(timeZone: TimeZone): AppointmentPresentation { + val availableAppointments = availableTimes.map { date -> val instant = Instant.parse(date) - val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + val localDateTime = instant.toLocalDateTime(timeZone) AppointmentDateTime(availableAppointmentDateTime = localDateTime) } diff --git a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/confirmation/AppointmentConfirmationSection.kt b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/confirmation/AppointmentConfirmationSection.kt index 2427a66..293f7ec 100644 --- a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/confirmation/AppointmentConfirmationSection.kt +++ b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/confirmation/AppointmentConfirmationSection.kt @@ -36,6 +36,7 @@ import br.com.cauezito.schedrix.ui.tokens.Strings.CONFIRMATION_SECTION_INPUT_NAM import br.com.cauezito.schedrix.ui.tokens.Strings.CONFIRMATION_SECTION_NAME_INPUT_SUPPORT_TEXT import br.com.cauezito.schedrix.ui.tokens.Strings.CONFIRMATION_SECTION_TITLE import br.com.cauezito.schedrix.ui.tokens.Strings.CONFIRMATION_SECTION_TOP_BAR_CONTENT_DESCRIPTION +import kotlinx.datetime.TimeZone @Composable internal fun AppointmentConfirmationSection( @@ -51,6 +52,7 @@ internal fun AppointmentConfirmationSection( redirectLink = generateGoogleCalendarLink( userName = name, startDateTime = state.finalSelectedDateTime, + timezone = TimeZone.of(state.currentTimezone.replace(" - ", "/").replace(" ", "_")) ) ) } diff --git a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeScreen.kt b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeScreen.kt index c64ea34..1fd87f7 100644 --- a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeScreen.kt +++ b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeScreen.kt @@ -28,6 +28,7 @@ internal class AppointmentTimeScreen() : Screen { AppointmentTimeScreenContent( state = state, onSelectedTime = onSelectedTime, + onTimezoneChange = screenModel::changeTimezone, onBackPressed = onBackPressed ) } @@ -36,11 +37,13 @@ internal class AppointmentTimeScreen() : Screen { private fun AppointmentTimeScreenContent( state: AppointmentState, onSelectedTime: (AppointmentDateTime) -> Unit, + onTimezoneChange: (String) -> Unit, onBackPressed: () -> Unit ) { AppointmentTimeSelectionSection( state = state, onSelectedTime = onSelectedTime, + onTimezoneChange = onTimezoneChange, onBackPressed ) } diff --git a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeSelectionSection.kt b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeSelectionSection.kt index 604450c..f9ad24c 100644 --- a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeSelectionSection.kt +++ b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/presentation/screens/time/AppointmentTimeSelectionSection.kt @@ -22,6 +22,7 @@ import br.com.cauezito.schedrix.presentation.AppointmentState import br.com.cauezito.schedrix.presentation.model.AppointmentDateTime import br.com.cauezito.schedrix.ui.components.shared.ScaffoldStructure import br.com.cauezito.schedrix.ui.components.shared.TimeSelectorItem +import br.com.cauezito.schedrix.ui.components.shared.TimezoneSelector import br.com.cauezito.schedrix.ui.tokens.Dimens.dimens_100 import br.com.cauezito.schedrix.ui.tokens.Dimens.dimens_12 import br.com.cauezito.schedrix.ui.tokens.Dimens.dimens_14 @@ -41,6 +42,7 @@ import br.com.cauezito.schedrix.ui.tokens.Strings.appointmentQuestion internal fun AppointmentTimeSelectionSection( state: AppointmentState, onSelectedTime: (AppointmentDateTime) -> Unit, + onTimezoneChange: (String) -> Unit, onBackPressed: () -> Unit ) { ScaffoldStructure( @@ -90,10 +92,9 @@ internal fun AppointmentTimeSelectionSection( color = Color.Black, ) - Text( - text = state.currentTimezone, - style = MaterialTheme.typography.bodyMedium, - color = Color.Black + TimezoneSelector( + current = state.currentTimezone, + onTimezoneSelected = onTimezoneChange ) } } diff --git a/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/ui/components/shared/TimezoneSelector.kt b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/ui/components/shared/TimezoneSelector.kt new file mode 100644 index 0000000..4fd6cf4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/br/com/cauezito/schedrix/ui/components/shared/TimezoneSelector.kt @@ -0,0 +1,55 @@ +package br.com.cauezito.schedrix.ui.components.shared + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import br.com.cauezito.schedrix.extensions.StringExtensions.formatTimezone +import br.com.cauezito.schedrix.presentation.model.timezones + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TimezoneSelector( + current: String, + onTimezoneSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = modifier + ) { + // Prevent manual typing so the user picks from the dropdown instead + OutlinedTextField( + value = current.formatTimezone(), + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, + modifier = Modifier.menuAnchor() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + timezones.forEach { zone -> + DropdownMenuItem( + text = { Text(zone.formatTimezone()) }, + onClick = { + onTimezoneSelected(zone) + expanded = false + } + ) + } + } + } +} diff --git a/composeApp/src/commonTest/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModelTest.kt b/composeApp/src/commonTest/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModelTest.kt index 228f9bc..49d336c 100644 --- a/composeApp/src/commonTest/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModelTest.kt +++ b/composeApp/src/commonTest/kotlin/br/com/cauezito/schedrix/presentation/AppointmentScreenModelTest.kt @@ -265,4 +265,18 @@ internal class AppointmentScreenModelTest { assertFalse(state.showContentLoading) assertTrue(state.calendarDays.isNotEmpty()) } + + @Test + fun `WHEN changeTimezone THEN updates timezone and recalculates times`() = runTest(dispatcher) { + screenModel.fetchAvailableTimes() + advanceUntilIdle() + screenModel.changeSelectedDate(fakeDate) + val firstTime = screenModel.state.value.selectedDateTimes.first() + + screenModel.changeTimezone("Asia/Tokyo") + + val updatedState = screenModel.state.value + assertTrue(updatedState.currentTimezone.contains("Asia")) + assertNotEquals(firstTime, updatedState.selectedDateTimes.first()) + } } \ No newline at end of file