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.
This commit is contained in:
2025-06-13 23:09:48 +02:00
parent b5772aac7b
commit 6f6b886418
3 changed files with 184 additions and 2 deletions

View File

@ -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 = {},
)
}
}

View File

@ -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<AirportInfo?>(null)
private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(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<AirportInfo?>,
selectedDestinationAirport: MutableStateFlow<AirportInfo?>,
selectedDate: MutableStateFlow<LocalDate>,
): Flow<HomeUiState> {
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<AirportInfo>,
val selectedOriginAirport: AirportInfo? = null,
val selectedDestinationAirport: AirportInfo? = null,
val selectedDate: LocalDate = LocalDate.now(),
) : HomeUiState
data class Error(val exception: Throwable) : HomeUiState

View File

@ -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<Interaction>(
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
}
}