From 504f798bd3e31d00a9c966db53569e3698eeb2db Mon Sep 17 00:00:00 2001 From: Adrian Kuta Date: Sun, 15 Jun 2025 23:41:00 +0200 Subject: [PATCH] feat: Implement airport search and selection in StationsScreen This commit introduces search functionality and airport selection to the StationsScreen. Users can now search for airports and select an origin airport, which will be displayed in a compact view. Key changes: - Added a search bar (`OutlinedTextField`) to `StationsScreen` to filter the list of airports. - Implemented `searchQuery` StateFlow in `StationsScreenViewModel` to hold the current search term. - Updated `originAirportsUiState` to filter airports based on the `searchQuery`. - Added `selectedOriginAirport` StateFlow in `StationsScreenViewModel` to manage the selected airport. - Introduced `AirportInfoCompact` composable to display the selected origin airport. - Implemented `onAirportSelected` and `clearSelectedAirport` functions in `StationsScreenViewModel` to handle airport selection and clearing. - Added `BackHandler` to clear the selected airport when the back button is pressed. - Made `AirportInfoItem` clickable to trigger airport selection. - Added string resource for "Search airports" placeholder. - Added Detekt configuration files for `domain/stations` and `ui/stations` modules. - Added Lint baseline files for `domain/stations` and `ui/stations` modules. - Used `key` and `animateItem` for better performance and animations in `LazyColumn`. --- domain/stations/config/detekt/detekt.yml | 10 +++ domain/stations/lint-baseline.xml | 11 +++ ui/stations/config/detekt/detekt.yml | 33 +++++++ ui/stations/lint-baseline.xml | 18 ++++ .../flights/ui/home/StationsScreen.kt | 86 +++++++++++++++++-- .../ui/home/StationsScreenViewModel.kt | 54 ++++++++++-- .../home/components/AirportInfoCompactItem.kt | 60 +++++++++++++ .../ui/home/components/AirportInfoItem.kt | 2 + ui/stations/src/main/res/values/strings.xml | 3 +- 9 files changed, 261 insertions(+), 16 deletions(-) create mode 100644 domain/stations/config/detekt/detekt.yml create mode 100644 domain/stations/lint-baseline.xml create mode 100644 ui/stations/config/detekt/detekt.yml create mode 100644 ui/stations/lint-baseline.xml create mode 100644 ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoCompactItem.kt diff --git a/domain/stations/config/detekt/detekt.yml b/domain/stations/config/detekt/detekt.yml new file mode 100644 index 0000000..809b757 --- /dev/null +++ b/domain/stations/config/detekt/detekt.yml @@ -0,0 +1,10 @@ +# Deviations from defaults +formatting: + TrailingCommaOnCallSite: + active: true + autoCorrect: true + useTrailingCommaOnCallSite: true + TrailingCommaOnDeclarationSite: + active: true + autoCorrect: true + useTrailingCommaOnDeclarationSite: true \ No newline at end of file diff --git a/domain/stations/lint-baseline.xml b/domain/stations/lint-baseline.xml new file mode 100644 index 0000000..f4dbe40 --- /dev/null +++ b/domain/stations/lint-baseline.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/ui/stations/config/detekt/detekt.yml b/ui/stations/config/detekt/detekt.yml new file mode 100644 index 0000000..aba4110 --- /dev/null +++ b/ui/stations/config/detekt/detekt.yml @@ -0,0 +1,33 @@ +# Exceptions for compose. See https://detekt.dev/docs/introduction/compose +naming: + FunctionNaming: + functionPattern: '[a-zA-Z][a-zA-Z0-9]*' + + TopLevelPropertyNaming: + constantPattern: '[A-Z][A-Za-z0-9]*' + +complexity: + LongParameterList: + ignoreAnnotated: ['Composable'] + TooManyFunctions: + ignoreAnnotatedFunctions: ['Preview'] + +style: + MagicNumber: + ignorePropertyDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotated: ['Composable'] + + UnusedPrivateMember: + ignoreAnnotated: ['Composable'] + +# Deviations from defaults +formatting: + TrailingCommaOnCallSite: + active: true + autoCorrect: true + useTrailingCommaOnCallSite: true + TrailingCommaOnDeclarationSite: + active: true + autoCorrect: true + useTrailingCommaOnDeclarationSite: true \ No newline at end of file diff --git a/ui/stations/lint-baseline.xml b/ui/stations/lint-baseline.xml new file mode 100644 index 0000000..5a9559d --- /dev/null +++ b/ui/stations/lint-baseline.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt index 886635b..6c936b0 100644 --- a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt @@ -1,53 +1,125 @@ package dev.adriankuta.flights.ui.home +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.Country +import dev.adriankuta.flights.ui.home.components.AirportInfoCompact import dev.adriankuta.flights.ui.home.components.AirportInfoItem import dev.adriankuta.flights.ui.home.components.CountryItem +import dev.adriankuta.flights.ui.stations.R @Composable internal fun StationsScreen( stationScreenViewModel: StationsScreenViewModel = hiltViewModel(), ) { val originAirports by stationScreenViewModel.originAirports.collectAsStateWithLifecycle() + val searchQuery by stationScreenViewModel.searchQuery.collectAsStateWithLifecycle() + val selectedOriginAirport by stationScreenViewModel.selectedOriginAirport.collectAsStateWithLifecycle() + + BackHandler(selectedOriginAirport != null) { + stationScreenViewModel.clearSelectedAirport() + } StationsScreen( originAirports = originAirports, + searchQuery = searchQuery, + selectedOriginAirport = selectedOriginAirport, + onSearchQueryChang = stationScreenViewModel::onSearchQueryChanged, + onAirportSelect = stationScreenViewModel::onAirportSelected, ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun StationsScreen( originAirports: OriginAirportsUiState, + searchQuery: String, + selectedOriginAirport: AirportInfo?, + onSearchQueryChang: (String) -> Unit, + onAirportSelect: (AirportInfo) -> Unit, ) { - LazyColumn( + Column( modifier = Modifier.fillMaxWidth(), ) { - when (originAirports) { - is OriginAirportsUiState.Error -> item { Text("Error") } - is OriginAirportsUiState.Loading -> item { Text("Loading") } - is OriginAirportsUiState.Success -> groupedAirports(originAirports.groupedAirports) + AnimatedVisibility(selectedOriginAirport != null) { + var airportInfo by remember { mutableStateOf(selectedOriginAirport!!) } + LaunchedEffect(selectedOriginAirport) { + selectedOriginAirport?.let { airportInfo = it } + } + AirportInfoCompact( + data = airportInfo, + ) + } + + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChang, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + placeholder = { Text(text = stringResource(R.string.search_airports)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + when (originAirports) { + is OriginAirportsUiState.Error -> item { Text("Error") } + is OriginAirportsUiState.Loading -> item { Text("Loading") } + is OriginAirportsUiState.Success -> groupedAirports( + data = originAirports.groupedAirports, + onAirportSelected = onAirportSelect, + ) + } } } } private fun LazyListScope.groupedAirports( data: Map>, + onAirportSelected: (AirportInfo) -> Unit, ) { data.keys.forEach { country -> - stickyHeader { CountryItem(country) } + stickyHeader { + key(country.code) { + CountryItem(data = country, modifier = Modifier.animateItem()) + } + } items(data[country]!!) { airport -> - AirportInfoItem(airport) + key(airport.code) { + AirportInfoItem( + data = airport, + modifier = Modifier.animateItem(), + onClick = onAirportSelected, + ) + } } } } diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt index 154833a..54d1c33 100644 --- a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt @@ -9,8 +9,10 @@ import dev.adriankuta.flights.domain.stations.ObserveAirportsGroupedByCountry import dev.adriankuta.flights.domain.types.AirportInfo import dev.adriankuta.flights.domain.types.Country import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -19,8 +21,27 @@ internal class StationsScreenViewModel @Inject constructor( observeAirportsGroupedByCountry: ObserveAirportsGroupedByCountry, ) : ViewModel() { + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery + + private val _selectedOriginAirport = MutableStateFlow(null) + val selectedOriginAirport: StateFlow = _selectedOriginAirport + + fun onSearchQueryChanged(query: String) { + _searchQuery.value = query + } + + fun onAirportSelected(airport: AirportInfo) { + _selectedOriginAirport.value = airport + } + + fun clearSelectedAirport() { + _selectedOriginAirport.value = null + } + val originAirports = originAirportsUiState( useCase = observeAirportsGroupedByCountry, + searchQuery = searchQuery, ).stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -30,16 +51,33 @@ internal class StationsScreenViewModel @Inject constructor( private fun originAirportsUiState( useCase: ObserveAirportsGroupedByCountry, + searchQuery: StateFlow, ): Flow { - return useCase() - .asResult() - .map { result -> - when (result) { - is Result.Error -> OriginAirportsUiState.Error(result.exception) - is Result.Loading -> OriginAirportsUiState.Loading - is Result.Success -> OriginAirportsUiState.Success(result.data.orEmpty()) + return combine( + useCase().asResult(), + searchQuery, + ) { result, query -> + when (result) { + is Result.Error -> OriginAirportsUiState.Error(result.exception) + is Result.Loading -> OriginAirportsUiState.Loading + is Result.Success -> { + val airports = result.data.orEmpty() + if (query.isBlank()) { + OriginAirportsUiState.Success(airports) + } else { + val filteredAirports = airports.mapValues { (_, airportList) -> + airportList.filter { airport -> + airport.name.contains(query, ignoreCase = true) || + airport.code.contains(query, ignoreCase = true) || + airport.city.name.contains(query, ignoreCase = true) || + airport.country.name.contains(query, ignoreCase = true) + } + }.filterValues { it.isNotEmpty() } + OriginAirportsUiState.Success(filteredAirports) + } } } + } } internal sealed interface OriginAirportsUiState { diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoCompactItem.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoCompactItem.kt new file mode 100644 index 0000000..1462455 --- /dev/null +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoCompactItem.kt @@ -0,0 +1,60 @@ +package dev.adriankuta.flights.ui.home.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.adriankuta.flights.domain.types.AirportInfo + +@Composable +internal fun AirportInfoCompact( + data: AirportInfo, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = data.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${data.code} • ${data.city.name}", + style = MaterialTheme.typography.bodyMedium, + ) + } + if (data.isBase) { + Text( + text = "BASE", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoItem.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoItem.kt index ccedddc..acb6187 100644 --- a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoItem.kt +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoItem.kt @@ -19,12 +19,14 @@ import dev.adriankuta.flights.domain.types.AirportInfo internal fun AirportInfoItem( data: AirportInfo, modifier: Modifier = Modifier, + onClick: (AirportInfo) -> Unit = {}, ) { Card( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + onClick = { onClick(data) }, ) { Column( modifier = Modifier diff --git a/ui/stations/src/main/res/values/strings.xml b/ui/stations/src/main/res/values/strings.xml index f8639e5..11281cb 100644 --- a/ui/stations/src/main/res/values/strings.xml +++ b/ui/stations/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Stations - \ No newline at end of file + Search airports +