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`.
This commit is contained in:
2025-06-15 23:41:00 +02:00
parent 3e9768919d
commit 504f798bd3
9 changed files with 261 additions and 16 deletions

View File

@ -0,0 +1,10 @@
# Deviations from defaults
formatting:
TrailingCommaOnCallSite:
active: true
autoCorrect: true
useTrailingCommaOnCallSite: true
TrailingCommaOnDeclarationSite:
active: true
autoCorrect: true
useTrailingCommaOnDeclarationSite: true

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.10.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.10.1)" variant="all" version="8.10.1">
<issue
id="Aligned16KB"
message="The native library `arm64-v8a/libmockkjvmtiagent.so` (from `io.mockk:mockk-agent-android:1.14.2`) is not 16 KB aligned">
<location
file="$GRADLE_USER_HOME/caches/8.11.1/transforms/60089ed8c197d152569cb75de3649edc/transformed/mockk-agent-android-1.14.2/jni/arm64-v8a/libmockkjvmtiagent.so"/>
</issue>
</issues>

View File

@ -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

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.10.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.10.1)" variant="all" version="8.10.1">
<issue
id="Aligned16KB"
message="The native library `arm64-v8a/libmockkjvmtiagent.so` (from `io.mockk:mockk-agent-android:1.14.2`) is not 16 KB aligned">
<location
file="$GRADLE_USER_HOME/caches/8.11.1/transforms/60089ed8c197d152569cb75de3649edc/transformed/mockk-agent-android-1.14.2/jni/arm64-v8a/libmockkjvmtiagent.so"/>
</issue>
<issue
id="Aligned16KB"
message="The native library `arm64-v8a/libmockkjvmtiagent.so` (from `io.mockk:mockk-agent-android:1.14.2`) is not 16 KB aligned">
<location
file="$GRADLE_USER_HOME/caches/8.11.1/transforms/60089ed8c197d152569cb75de3649edc/transformed/mockk-agent-android-1.14.2/jni/arm64-v8a/libmockkjvmtiagent.so"/>
</issue>
</issues>

View File

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

View File

@ -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<String> = _searchQuery
private val _selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
val selectedOriginAirport: StateFlow<AirportInfo?> = _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<String>,
): Flow<OriginAirportsUiState> {
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 {

View File

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

View File

@ -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

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="stations_screen_title">Stations</string>
<string name="search_airports">Search airports</string>
</resources>