mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-07-01 15:27:59 +02: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
|
||||
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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