mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-07-01 20:28:00 +02:00
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:
10
domain/stations/config/detekt/detekt.yml
Normal file
10
domain/stations/config/detekt/detekt.yml
Normal file
@ -0,0 +1,10 @@
|
||||
# Deviations from defaults
|
||||
formatting:
|
||||
TrailingCommaOnCallSite:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
useTrailingCommaOnCallSite: true
|
||||
TrailingCommaOnDeclarationSite:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
useTrailingCommaOnDeclarationSite: true
|
11
domain/stations/lint-baseline.xml
Normal file
11
domain/stations/lint-baseline.xml
Normal 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>
|
33
ui/stations/config/detekt/detekt.yml
Normal file
33
ui/stations/config/detekt/detekt.yml
Normal 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
|
18
ui/stations/lint-baseline.xml
Normal file
18
ui/stations/lint-baseline.xml
Normal 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>
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="stations_screen_title">Stations</string>
|
||||
</resources>
|
||||
<string name="search_airports">Search airports</string>
|
||||
</resources>
|
||||
|
Reference in New Issue
Block a user