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:
2025-06-13 22:36:49 +02:00
parent 7e70d8e62d
commit b5772aac7b
3 changed files with 208 additions and 18 deletions

View File

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

View File

@ -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<AirportInfo?>(null)
private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(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<AirportInfo?>,
selectedDestinationAirport: MutableStateFlow<AirportInfo?>,
): Flow<HomeUiState> {
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<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
}

View File

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