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 +