From b5772aac7bca72c0c27923fcfdf4a13944abe326 Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Fri, 13 Jun 2025 22:36:49 +0200 Subject: [PATCH] feat: Implement origin and destination airport selection This commit introduces functionality for selecting origin and destination airports on the home screen. Key changes: - Modified `HomeScreen` to use `Column` instead of `LazyColumn` for layout. - Added `AirportDropdown` composable component for selecting airports. - Updated `HomeScreen` to include two `AirportDropdown` instances: one for origin and one for destination. - Enhanced `HomeScreenViewModel`: - Added `selectedOriginAirport` and `selectedDestinationAirport` state flows. - Implemented `selectOriginAirport` and `selectDestinationAirport` functions to update selected airports. - Added `clearOriginAirport` and `clearDestinationAirport` functions. - Updated `homeUiState` to combine airport data with selected airports and filter destination airports to exclude the selected origin. - Updated `HomeUiState.Success` to include `originAirports`, `destinationAirports`, `selectedOriginAirport`, and `selectedDestinationAirport`. - Added new preview functions for `HomeScreen` in loading and success states with mock data. --- .../adriankuta/flights/ui/home/HomeScreen.kt | 96 +++++++++++++++++-- .../flights/ui/home/HomeScreenViewModel.kt | 64 +++++++++++-- .../ui/home/components/AirportDropdown.kt | 66 +++++++++++++ 3 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportDropdown.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 33516e1..c64062f 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 @@ -1,15 +1,26 @@ package dev.adriankuta.flights.ui.home -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.adriankuta.flights.domain.types.AirportInfo +import dev.adriankuta.flights.domain.types.City +import dev.adriankuta.flights.domain.types.Coordinates +import dev.adriankuta.flights.domain.types.Country +import dev.adriankuta.flights.domain.types.MacCity +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 @Composable internal fun HomeScreen( @@ -19,22 +30,43 @@ internal fun HomeScreen( HomeScreen( uiState = homeUiState, + onOriginAirportSelect = viewModel::selectOriginAirport, + onDestinationAirportSelect = viewModel::selectDestinationAirport, ) } @Composable private fun HomeScreen( uiState: HomeUiState, + onOriginAirportSelect: (AirportInfo) -> Unit, + onDestinationAirportSelect: (AirportInfo) -> Unit, modifier: Modifier = Modifier, ) { - LazyColumn( - modifier = modifier, + Column( + modifier = modifier.padding(16.dp), ) { when (uiState) { - is HomeUiState.Error -> item { Text("Error") } - HomeUiState.Loading -> item { Text("Loading") } - is HomeUiState.Success -> items(uiState.airports) { airport -> - Text(airport.name) + is HomeUiState.Error -> Text("Error") + HomeUiState.Loading -> Text("Loading") + is HomeUiState.Success -> { + AirportDropdown( + label = "Origin Airport", + airports = uiState.originAirports, + selectedAirport = uiState.selectedOriginAirport, + onAirportSelect = onOriginAirportSelect, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + AirportDropdown( + label = "Destination Airport", + airports = uiState.destinationAirports, + selectedAirport = uiState.selectedDestinationAirport, + onAirportSelect = onDestinationAirportSelect, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.selectedOriginAirport != null, + ) } } } @@ -42,10 +74,56 @@ private fun HomeScreen( @PreviewDevices @Composable -private fun HomeScreenPreview() { +private fun HomeScreenLoadingPreview() { FlightsTheme { HomeScreen( uiState = HomeUiState.Loading, + onOriginAirportSelect = {}, + onDestinationAirportSelect = {}, + ) + } +} + +@PreviewDevices +@Composable +private fun HomeScreenSuccessPreview() { + FlightsTheme { + val mockAirports = listOf( + AirportInfo( + code = "WAW", + name = "Warsaw Chopin Airport", + seoName = "warsaw", + isBase = true, + timeZone = "Europe/Warsaw", + city = City("WAW", "Warsaw"), + macCity = MacCity("WARSAW"), + region = Region("WARSAW_PL", "Warsaw"), + country = Country("PL", "Poland", "PLN"), + coordinates = Coordinates(52.1657, 20.9671), + ), + AirportInfo( + code = "KRK", + name = "Krakow Airport", + seoName = "krakow", + isBase = true, + timeZone = "Europe/Warsaw", + city = City("KRK", "Krakow"), + macCity = MacCity("KRAKOW"), + region = Region("KRAKOW_PL", "Krakow"), + country = Country("PL", "Poland", "PLN"), + coordinates = Coordinates(50.0777, 19.7848), + ), + ) + + HomeScreen( + uiState = HomeUiState.Success( + originAirports = mockAirports, + destinationAirports = mockAirports, + selectedOriginAirport = mockAirports.first(), + selectedDestinationAirport = null, + ), + onOriginAirportSelect = {}, + onDestinationAirportSelect = {}, ) } } 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 e8b9748..c59f727 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 @@ -8,8 +8,9 @@ import dev.adriankuta.flights.core.util.asResult import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase import dev.adriankuta.flights.domain.types.AirportInfo import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -18,32 +19,77 @@ class HomeScreenViewModel @Inject constructor( private val observeAirportsUseCase: ObserveAirportsUseCase, ) : ViewModel() { + private val selectedOriginAirport = MutableStateFlow(null) + private val selectedDestinationAirport = MutableStateFlow(null) + internal val uiState = homeUiState( useCase = observeAirportsUseCase, + selectedOriginAirport = selectedOriginAirport, + selectedDestinationAirport = selectedDestinationAirport, ) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = HomeUiState.Loading, ) + + fun selectOriginAirport(airport: AirportInfo) { + selectedOriginAirport.value = airport + // If the selected destination is the same as the new origin, clear the destination + if (selectedDestinationAirport.value?.code == airport.code) { + selectedDestinationAirport.value = null + } + } + + fun selectDestinationAirport(airport: AirportInfo) { + selectedDestinationAirport.value = airport + } + + fun clearOriginAirport() { + selectedOriginAirport.value = null + // Clear destination as well since it depends on origin + selectedDestinationAirport.value = null + } + + fun clearDestinationAirport() { + selectedDestinationAirport.value = null + } } private fun homeUiState( useCase: ObserveAirportsUseCase, + selectedOriginAirport: MutableStateFlow, + selectedDestinationAirport: MutableStateFlow, ): Flow { - return useCase() - .asResult() - .map { result -> - when (result) { - is Result.Error -> HomeUiState.Error(result.exception) - Result.Loading -> HomeUiState.Loading - is Result.Success -> HomeUiState.Success(result.data.orEmpty()) + return combine( + useCase().asResult(), + selectedOriginAirport, + selectedDestinationAirport, + ) { result, origin, destination -> + when (result) { + is Result.Error -> HomeUiState.Error(result.exception) + is Result.Loading -> HomeUiState.Loading + is Result.Success -> { + val airports = result.data.orEmpty() + HomeUiState.Success( + originAirports = airports, + destinationAirports = airports.filter { it.code != origin?.code }, + selectedOriginAirport = origin, + selectedDestinationAirport = destination, + ) } } + } } internal sealed interface HomeUiState { data object Loading : HomeUiState - data class Success(val airports: List) : HomeUiState + data class Success( + val originAirports: List, + val destinationAirports: List, + val selectedOriginAirport: AirportInfo? = null, + val selectedDestinationAirport: AirportInfo? = null, + ) : HomeUiState + data class Error(val exception: Throwable) : HomeUiState } diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportDropdown.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportDropdown.kt new file mode 100644 index 0000000..f244252 --- /dev/null +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportDropdown.kt @@ -0,0 +1,66 @@ +package dev.adriankuta.flights.ui.home.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MenuAnchorType +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 dev.adriankuta.flights.domain.types.AirportInfo + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AirportDropdown( + label: String, + airports: List, + selectedAirport: AirportInfo?, + onAirportSelect: (AirportInfo) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { if (enabled) expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = selectedAirport?.let { "${it.name} (${it.code})" } ?: "", + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .fillMaxWidth() + .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled), + enabled = enabled, + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + airports.forEach { airport -> + DropdownMenuItem( + text = { Text("${airport.name} (${airport.code})") }, + onClick = { + onAirportSelect(airport) + expanded = false + }, + ) + } + } + } +}