mirror of
				https://github.com/AdrianKuta/android-challange-adrian-kuta.git
				synced 2025-10-31 05:43:40 +01: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