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

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.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
} }

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