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
+