mirror of
				https://github.com/AdrianKuta/android-challange-adrian-kuta.git
				synced 2025-10-31 05:43:40 +01:00 
			
		
		
		
	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.
			
			
This commit is contained in:
		| @@ -1,15 +1,26 @@ | |||||||
| package dev.adriankuta.flights.ui.home | package dev.adriankuta.flights.ui.home | ||||||
|  |  | ||||||
| import androidx.compose.foundation.lazy.LazyColumn | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.lazy.items | 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.material3.Text | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
| import androidx.hilt.navigation.compose.hiltViewModel | import androidx.hilt.navigation.compose.hiltViewModel | ||||||
| import androidx.lifecycle.compose.collectAsStateWithLifecycle | 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.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 | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| internal fun HomeScreen( | internal fun HomeScreen( | ||||||
| @@ -19,22 +30,43 @@ internal fun HomeScreen( | |||||||
|  |  | ||||||
|     HomeScreen( |     HomeScreen( | ||||||
|         uiState = homeUiState, |         uiState = homeUiState, | ||||||
|  |         onOriginAirportSelect = viewModel::selectOriginAirport, | ||||||
|  |         onDestinationAirportSelect = viewModel::selectDestinationAirport, | ||||||
|     ) |     ) | ||||||
| } | } | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| private fun HomeScreen( | private fun HomeScreen( | ||||||
|     uiState: HomeUiState, |     uiState: HomeUiState, | ||||||
|  |     onOriginAirportSelect: (AirportInfo) -> Unit, | ||||||
|  |     onDestinationAirportSelect: (AirportInfo) -> Unit, | ||||||
|     modifier: Modifier = Modifier, |     modifier: Modifier = Modifier, | ||||||
| ) { | ) { | ||||||
|     LazyColumn( |     Column( | ||||||
|         modifier = modifier, |         modifier = modifier.padding(16.dp), | ||||||
|     ) { |     ) { | ||||||
|         when (uiState) { |         when (uiState) { | ||||||
|             is HomeUiState.Error -> item { Text("Error") } |             is HomeUiState.Error -> Text("Error") | ||||||
|             HomeUiState.Loading -> item { Text("Loading") } |             HomeUiState.Loading -> Text("Loading") | ||||||
|             is HomeUiState.Success -> items(uiState.airports) { airport -> |             is HomeUiState.Success -> { | ||||||
|                 Text(airport.name) |                 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 | @PreviewDevices | ||||||
| @Composable | @Composable | ||||||
| private fun HomeScreenPreview() { | private fun HomeScreenLoadingPreview() { | ||||||
|     FlightsTheme { |     FlightsTheme { | ||||||
|         HomeScreen( |         HomeScreen( | ||||||
|             uiState = HomeUiState.Loading, |             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 = {}, | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,8 +8,9 @@ import dev.adriankuta.flights.core.util.asResult | |||||||
| import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase | import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase | ||||||
| import dev.adriankuta.flights.domain.types.AirportInfo | import dev.adriankuta.flights.domain.types.AirportInfo | ||||||
| import kotlinx.coroutines.flow.Flow | import kotlinx.coroutines.flow.Flow | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
| import kotlinx.coroutines.flow.SharingStarted | import kotlinx.coroutines.flow.SharingStarted | ||||||
| import kotlinx.coroutines.flow.map | import kotlinx.coroutines.flow.combine | ||||||
| import kotlinx.coroutines.flow.stateIn | import kotlinx.coroutines.flow.stateIn | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  |  | ||||||
| @@ -18,32 +19,77 @@ class HomeScreenViewModel @Inject constructor( | |||||||
|     private val observeAirportsUseCase: ObserveAirportsUseCase, |     private val observeAirportsUseCase: ObserveAirportsUseCase, | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|  |  | ||||||
|  |     private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null) | ||||||
|  |     private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(null) | ||||||
|  |  | ||||||
|     internal val uiState = homeUiState( |     internal val uiState = homeUiState( | ||||||
|         useCase = observeAirportsUseCase, |         useCase = observeAirportsUseCase, | ||||||
|  |         selectedOriginAirport = selectedOriginAirport, | ||||||
|  |         selectedDestinationAirport = selectedDestinationAirport, | ||||||
|     ) |     ) | ||||||
|         .stateIn( |         .stateIn( | ||||||
|             scope = viewModelScope, |             scope = viewModelScope, | ||||||
|             started = SharingStarted.WhileSubscribed(5_000), |             started = SharingStarted.WhileSubscribed(5_000), | ||||||
|             initialValue = HomeUiState.Loading, |             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( | private fun homeUiState( | ||||||
|     useCase: ObserveAirportsUseCase, |     useCase: ObserveAirportsUseCase, | ||||||
|  |     selectedOriginAirport: MutableStateFlow<AirportInfo?>, | ||||||
|  |     selectedDestinationAirport: MutableStateFlow<AirportInfo?>, | ||||||
| ): Flow<HomeUiState> { | ): Flow<HomeUiState> { | ||||||
|     return useCase() |     return combine( | ||||||
|         .asResult() |         useCase().asResult(), | ||||||
|         .map { result -> |         selectedOriginAirport, | ||||||
|  |         selectedDestinationAirport, | ||||||
|  |     ) { result, origin, destination -> | ||||||
|         when (result) { |         when (result) { | ||||||
|             is Result.Error -> HomeUiState.Error(result.exception) |             is Result.Error -> HomeUiState.Error(result.exception) | ||||||
|                 Result.Loading -> HomeUiState.Loading |             is Result.Loading -> HomeUiState.Loading | ||||||
|                 is Result.Success -> HomeUiState.Success(result.data.orEmpty()) |             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 { | internal sealed interface HomeUiState { | ||||||
|     data object Loading : HomeUiState |     data object Loading : HomeUiState | ||||||
|     data class Success(val airports: List<AirportInfo>) : HomeUiState |     data class Success( | ||||||
|  |         val originAirports: List<AirportInfo>, | ||||||
|  |         val destinationAirports: List<AirportInfo>, | ||||||
|  |         val selectedOriginAirport: AirportInfo? = null, | ||||||
|  |         val selectedDestinationAirport: AirportInfo? = null, | ||||||
|  |     ) : HomeUiState | ||||||
|  |  | ||||||
|     data class Error(val exception: Throwable) : HomeUiState |     data class Error(val exception: Throwable) : HomeUiState | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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<AirportInfo>, | ||||||
|  |     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 | ||||||
|  |                     }, | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user