From 6f6b88641804c322ceae652145c6a1750faaba82 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Fri, 13 Jun 2025 23:09:48 +0200 Subject: [PATCH] feat: Add DatePicker to HomeScreen for flight date selection This commit introduces a DatePicker component to the HomeScreen, allowing users to select a departure date for their flight search. Key changes: - Created a reusable `DatePicker` composable in `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt`. - This component uses Material 3 `DatePicker` and `DatePickerDialog`. - It ensures that only future dates can be selected using a custom `FutureSelectableDates` class. - The date is displayed in "yyyy-MM-dd" format. - Clicking the `OutlinedTextField` opens the `DatePickerDialog`. - Integrated the `DatePicker` into `HomeScreen.kt`. - The `DatePicker` is placed below the destination airport dropdown. - It is enabled only after a destination airport is selected. - The selected date is passed from and updated in the `HomeScreenViewModel`. - Updated `HomeScreenViewModel.kt`: - Added a `selectedDate` `MutableStateFlow` initialized with the current date. - Implemented a `selectDate` function to update `selectedDate`. - Included `selectedDate` in the `homeUiState` flow and `HomeUiState.Success` data class. - Updated `HomeScreenPreview` and `HomeScreenPreviewSuccess` to include the new `onDateSelect` parameter and provide a `selectedDate` for the success state. --- .../adriankuta/flights/ui/home/HomeScreen.kt | 19 ++- .../flights/ui/home/HomeScreenViewModel.kt | 13 +- .../flights/ui/home/components/DatePicker.kt | 154 ++++++++++++++++++ 3 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt index c64062f..6a1268b 100644 --- a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt @@ -21,6 +21,8 @@ import dev.adriankuta.flights.domain.types.Region import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices import dev.adriankuta.flights.ui.home.components.AirportDropdown +import dev.adriankuta.flights.ui.home.components.DatePicker +import java.time.LocalDate @Composable internal fun HomeScreen( @@ -32,6 +34,7 @@ internal fun HomeScreen( uiState = homeUiState, onOriginAirportSelect = viewModel::selectOriginAirport, onDestinationAirportSelect = viewModel::selectDestinationAirport, + onDateSelect = viewModel::selectDate, ) } @@ -40,6 +43,7 @@ private fun HomeScreen( uiState: HomeUiState, onOriginAirportSelect: (AirportInfo) -> Unit, onDestinationAirportSelect: (AirportInfo) -> Unit, + onDateSelect: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -67,6 +71,16 @@ private fun HomeScreen( modifier = Modifier.fillMaxWidth(), enabled = uiState.selectedOriginAirport != null, ) + + Spacer(modifier = Modifier.height(16.dp)) + + DatePicker( + label = "Departure Date", + selectedDate = uiState.selectedDate, + onDateSelect = onDateSelect, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.selectedDestinationAirport != null, + ) } } } @@ -80,6 +94,7 @@ private fun HomeScreenLoadingPreview() { uiState = HomeUiState.Loading, onOriginAirportSelect = {}, onDestinationAirportSelect = {}, + onDateSelect = {}, ) } } @@ -120,10 +135,12 @@ private fun HomeScreenSuccessPreview() { originAirports = mockAirports, destinationAirports = mockAirports, selectedOriginAirport = mockAirports.first(), - selectedDestinationAirport = null, + selectedDestinationAirport = mockAirports.last(), + selectedDate = LocalDate.now(), ), onOriginAirportSelect = {}, onDestinationAirportSelect = {}, + onDateSelect = {}, ) } } diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt index c59f727..0ae4704 100644 --- a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import java.time.LocalDate import javax.inject.Inject @HiltViewModel @@ -21,11 +22,13 @@ class HomeScreenViewModel @Inject constructor( private val selectedOriginAirport = MutableStateFlow(null) private val selectedDestinationAirport = MutableStateFlow(null) + private val selectedDate = MutableStateFlow(LocalDate.now()) internal val uiState = homeUiState( useCase = observeAirportsUseCase, selectedOriginAirport = selectedOriginAirport, selectedDestinationAirport = selectedDestinationAirport, + selectedDate = selectedDate, ) .stateIn( scope = viewModelScope, @@ -54,18 +57,24 @@ class HomeScreenViewModel @Inject constructor( fun clearDestinationAirport() { selectedDestinationAirport.value = null } + + fun selectDate(date: LocalDate) { + selectedDate.value = date + } } private fun homeUiState( useCase: ObserveAirportsUseCase, selectedOriginAirport: MutableStateFlow, selectedDestinationAirport: MutableStateFlow, + selectedDate: MutableStateFlow, ): Flow { return combine( useCase().asResult(), selectedOriginAirport, selectedDestinationAirport, - ) { result, origin, destination -> + selectedDate, + ) { result, origin, destination, date -> when (result) { is Result.Error -> HomeUiState.Error(result.exception) is Result.Loading -> HomeUiState.Loading @@ -76,6 +85,7 @@ private fun homeUiState( destinationAirports = airports.filter { it.code != origin?.code }, selectedOriginAirport = origin, selectedDestinationAirport = destination, + selectedDate = date, ) } } @@ -89,6 +99,7 @@ internal sealed interface HomeUiState { val destinationAirports: List, val selectedOriginAirport: AirportInfo? = null, val selectedDestinationAirport: AirportInfo? = null, + val selectedDate: LocalDate = LocalDate.now(), ) : HomeUiState data class Error(val exception: Throwable) : HomeUiState diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt new file mode 100644 index 0000000..dffa683 --- /dev/null +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt @@ -0,0 +1,154 @@ +package dev.adriankuta.flights.ui.home.components + +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.platform.LocalFocusManager +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongMethod") +fun DatePicker( + label: String, + selectedDate: LocalDate, + onDateSelect: (LocalDate) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val focusManager = LocalFocusManager.current + var showDatePicker by remember { mutableStateOf(false) } + + // Ensure the selected date is not in the past + val validatedDate = remember(selectedDate) { + if (selectedDate.isBefore(LocalDate.now())) { + LocalDate.now() + } else { + selectedDate + } + } + + // Format the date for display + val formattedDate = remember(validatedDate) { + validatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) + } + val interactionSource = remember { + object : MutableInteractionSource { + override val interactions = MutableSharedFlow( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override suspend fun emit(interaction: Interaction) { + if (interaction is PressInteraction.Release) { + // Clicked + showDatePicker = true + focusManager.clearFocus() + } + + interactions.emit(interaction) + } + + override fun tryEmit(interaction: Interaction): Boolean { + return interactions.tryEmit(interaction) + } + } + } + + OutlinedTextField( + value = formattedDate, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + interactionSource = interactionSource, + modifier = modifier + .fillMaxWidth(), + enabled = enabled, + ) + + if (showDatePicker) { + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = selectedDate + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(), + selectableDates = FutureSelectableDates(), + ) + + val confirmEnabled by remember { + derivedStateOf { datePickerState.selectedDateMillis != null } + } + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis?.let { millis -> + val newDate = Instant.ofEpochMilli(millis) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + // Only allow present and future dates + if (!newDate.isBefore(LocalDate.now())) { + onDateSelect(newDate) + } + } + showDatePicker = false + }, + enabled = confirmEnabled, + ) { + Text("OK") + } + }, + dismissButton = { + TextButton( + onClick = { showDatePicker = false }, + ) { + Text("Cancel") + } + }, + ) { + DatePicker( + state = datePickerState, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +class FutureSelectableDates : SelectableDates { + private val now = LocalDate.now() + private val dayStart = now.atTime(0, 0, 0, 0).toEpochSecond(ZoneOffset.UTC) * 1000 + + @ExperimentalMaterial3Api + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return utcTimeMillis >= dayStart + } + + @ExperimentalMaterial3Api + override fun isSelectableYear(year: Int): Boolean { + return year >= now.year + } +}