mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-07-02 02:18:00 +02:00
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:
@ -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.FlightsTheme
|
||||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||||
import dev.adriankuta.flights.ui.home.components.AirportDropdown
|
import dev.adriankuta.flights.ui.home.components.AirportDropdown
|
||||||
|
import dev.adriankuta.flights.ui.home.components.DatePicker
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HomeScreen(
|
internal fun HomeScreen(
|
||||||
@ -32,6 +34,7 @@ internal fun HomeScreen(
|
|||||||
uiState = homeUiState,
|
uiState = homeUiState,
|
||||||
onOriginAirportSelect = viewModel::selectOriginAirport,
|
onOriginAirportSelect = viewModel::selectOriginAirport,
|
||||||
onDestinationAirportSelect = viewModel::selectDestinationAirport,
|
onDestinationAirportSelect = viewModel::selectDestinationAirport,
|
||||||
|
onDateSelect = viewModel::selectDate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +43,7 @@ private fun HomeScreen(
|
|||||||
uiState: HomeUiState,
|
uiState: HomeUiState,
|
||||||
onOriginAirportSelect: (AirportInfo) -> Unit,
|
onOriginAirportSelect: (AirportInfo) -> Unit,
|
||||||
onDestinationAirportSelect: (AirportInfo) -> Unit,
|
onDestinationAirportSelect: (AirportInfo) -> Unit,
|
||||||
|
onDateSelect: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@ -67,6 +71,16 @@ private fun HomeScreen(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = uiState.selectedOriginAirport != null,
|
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,
|
uiState = HomeUiState.Loading,
|
||||||
onOriginAirportSelect = {},
|
onOriginAirportSelect = {},
|
||||||
onDestinationAirportSelect = {},
|
onDestinationAirportSelect = {},
|
||||||
|
onDateSelect = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,10 +135,12 @@ private fun HomeScreenSuccessPreview() {
|
|||||||
originAirports = mockAirports,
|
originAirports = mockAirports,
|
||||||
destinationAirports = mockAirports,
|
destinationAirports = mockAirports,
|
||||||
selectedOriginAirport = mockAirports.first(),
|
selectedOriginAirport = mockAirports.first(),
|
||||||
selectedDestinationAirport = null,
|
selectedDestinationAirport = mockAirports.last(),
|
||||||
|
selectedDate = LocalDate.now(),
|
||||||
),
|
),
|
||||||
onOriginAirportSelect = {},
|
onOriginAirportSelect = {},
|
||||||
onDestinationAirportSelect = {},
|
onDestinationAirportSelect = {},
|
||||||
|
onDateSelect = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import java.time.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@ -21,11 +22,13 @@ class HomeScreenViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
||||||
private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(null)
|
private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(null)
|
||||||
|
private val selectedDate = MutableStateFlow(LocalDate.now())
|
||||||
|
|
||||||
internal val uiState = homeUiState(
|
internal val uiState = homeUiState(
|
||||||
useCase = observeAirportsUseCase,
|
useCase = observeAirportsUseCase,
|
||||||
selectedOriginAirport = selectedOriginAirport,
|
selectedOriginAirport = selectedOriginAirport,
|
||||||
selectedDestinationAirport = selectedDestinationAirport,
|
selectedDestinationAirport = selectedDestinationAirport,
|
||||||
|
selectedDate = selectedDate,
|
||||||
)
|
)
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@ -54,18 +57,24 @@ class HomeScreenViewModel @Inject constructor(
|
|||||||
fun clearDestinationAirport() {
|
fun clearDestinationAirport() {
|
||||||
selectedDestinationAirport.value = null
|
selectedDestinationAirport.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun selectDate(date: LocalDate) {
|
||||||
|
selectedDate.value = date
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun homeUiState(
|
private fun homeUiState(
|
||||||
useCase: ObserveAirportsUseCase,
|
useCase: ObserveAirportsUseCase,
|
||||||
selectedOriginAirport: MutableStateFlow<AirportInfo?>,
|
selectedOriginAirport: MutableStateFlow<AirportInfo?>,
|
||||||
selectedDestinationAirport: MutableStateFlow<AirportInfo?>,
|
selectedDestinationAirport: MutableStateFlow<AirportInfo?>,
|
||||||
|
selectedDate: MutableStateFlow<LocalDate>,
|
||||||
): Flow<HomeUiState> {
|
): Flow<HomeUiState> {
|
||||||
return combine(
|
return combine(
|
||||||
useCase().asResult(),
|
useCase().asResult(),
|
||||||
selectedOriginAirport,
|
selectedOriginAirport,
|
||||||
selectedDestinationAirport,
|
selectedDestinationAirport,
|
||||||
) { result, origin, destination ->
|
selectedDate,
|
||||||
|
) { result, origin, destination, date ->
|
||||||
when (result) {
|
when (result) {
|
||||||
is Result.Error -> HomeUiState.Error(result.exception)
|
is Result.Error -> HomeUiState.Error(result.exception)
|
||||||
is Result.Loading -> HomeUiState.Loading
|
is Result.Loading -> HomeUiState.Loading
|
||||||
@ -76,6 +85,7 @@ private fun homeUiState(
|
|||||||
destinationAirports = airports.filter { it.code != origin?.code },
|
destinationAirports = airports.filter { it.code != origin?.code },
|
||||||
selectedOriginAirport = origin,
|
selectedOriginAirport = origin,
|
||||||
selectedDestinationAirport = destination,
|
selectedDestinationAirport = destination,
|
||||||
|
selectedDate = date,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,6 +99,7 @@ internal sealed interface HomeUiState {
|
|||||||
val destinationAirports: List<AirportInfo>,
|
val destinationAirports: List<AirportInfo>,
|
||||||
val selectedOriginAirport: AirportInfo? = null,
|
val selectedOriginAirport: AirportInfo? = null,
|
||||||
val selectedDestinationAirport: AirportInfo? = null,
|
val selectedDestinationAirport: AirportInfo? = null,
|
||||||
|
val selectedDate: LocalDate = LocalDate.now(),
|
||||||
) : HomeUiState
|
) : HomeUiState
|
||||||
|
|
||||||
data class Error(val exception: Throwable) : HomeUiState
|
data class Error(val exception: Throwable) : HomeUiState
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user