feat: Implement stations screen with grouped airports

This commit introduces a new "Stations" screen that displays airports grouped by country.

Key changes:
- Added a new Gradle module: `ui:stations`.
- Created `StationsScreen.kt` to display a list of airports grouped by country using `LazyColumn`. Airports within each country are displayed as `AirportInfoItem` and countries as `CountryItem`.
- Implemented `StationsScreenViewModel.kt` to fetch and manage the state of airports grouped by country. It uses `ObserveAirportsGroupedByCountry` use case.
- Defined `ObserveAirportsGroupedByCountry.kt` use case in `domain:stations` module to provide a flow of airports grouped by country.
- Implemented `ObserveAirportsGroupedByCountryImpl.kt` in the repository layer, which fetches data using `AirportService`, stores it in `AirportsDatasource`, and maps it to the domain model.
- Added Hilt module `ObserveAirportsGroupedByCountryModule.kt` to provide the use case implementation.
- Added `stationsScreen()` and `navigateToStations()` to `FlightsNavGraph.kt` and `TopLevelDestination.kt` for navigation.
- Updated `settings.gradle.kts` and `app/build.gradle.kts` to include the new `ui:stations` and `domain:stations` modules.
- Updated `CacheObservers.kt` to make `mapToDomain` a suspend function.
- Added string resources for the stations screen title.
This commit is contained in:
2025-06-15 23:15:25 +02:00
parent 13348bc52f
commit 3e9768919d
18 changed files with 368 additions and 5 deletions

View File

@ -0,0 +1,18 @@
plugins {
alias(libs.plugins.flights.android.library.compose)
alias(libs.plugins.flights.android.library.hilt)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "dev.adriankuta.flights.ui.stations"
}
dependencies {
implementation(projects.core.util)
implementation(projects.ui.designsystem)
implementation(projects.ui.sharedui)
implementation(projects.domain.stations)
implementation(libs.androidx.hilt.navigation.compose)
}

View File

@ -0,0 +1,53 @@
package dev.adriankuta.flights.ui.home
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.AirportInfoItem
import dev.adriankuta.flights.ui.home.components.CountryItem
@Composable
internal fun StationsScreen(
stationScreenViewModel: StationsScreenViewModel = hiltViewModel(),
) {
val originAirports by stationScreenViewModel.originAirports.collectAsStateWithLifecycle()
StationsScreen(
originAirports = originAirports,
)
}
@Composable
private fun StationsScreen(
originAirports: OriginAirportsUiState,
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
) {
when (originAirports) {
is OriginAirportsUiState.Error -> item { Text("Error") }
is OriginAirportsUiState.Loading -> item { Text("Loading") }
is OriginAirportsUiState.Success -> groupedAirports(originAirports.groupedAirports)
}
}
}
private fun LazyListScope.groupedAirports(
data: Map<Country, List<AirportInfo>>,
) {
data.keys.forEach { country ->
stickyHeader { CountryItem(country) }
items(data[country]!!) { airport ->
AirportInfoItem(airport)
}
}
}

View File

@ -0,0 +1,55 @@
package dev.adriankuta.flights.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.adriankuta.flights.core.util.Result
import dev.adriankuta.flights.core.util.asResult
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.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
internal class StationsScreenViewModel @Inject constructor(
observeAirportsGroupedByCountry: ObserveAirportsGroupedByCountry,
) : ViewModel() {
val originAirports = originAirportsUiState(
useCase = observeAirportsGroupedByCountry,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = OriginAirportsUiState.Loading,
)
}
private fun originAirportsUiState(
useCase: ObserveAirportsGroupedByCountry,
): 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())
}
}
}
internal sealed interface OriginAirportsUiState {
data object Loading : OriginAirportsUiState
data class Success(val groupedAirports: Map<Country, List<AirportInfo>>) : OriginAirportsUiState
data class Error(val exception: Throwable) : OriginAirportsUiState
}
internal sealed interface DestinationAirportsUiState {
data object Loading : DestinationAirportsUiState
data class Success(val airports: List<AirportInfo>) : DestinationAirportsUiState
data class Error(val exception: Throwable) : DestinationAirportsUiState
}

View File

@ -0,0 +1,72 @@
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 AirportInfoItem(
data: AirportInfo,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = data.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = data.code,
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),
)
}
}
Text(
text = "City: ${data.city.name}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = "Region: ${data.region.name}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}

View File

@ -0,0 +1,48 @@
package dev.adriankuta.flights.ui.home.components
import androidx.compose.foundation.background
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.MaterialTheme
import androidx.compose.material3.Surface
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.Country
@Composable
internal fun CountryItem(
data: Country,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.primaryContainer),
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = data.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = "Currency: ${data.currencyCode}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}

View File

@ -0,0 +1,25 @@
@file:Suppress("MatchingDeclarationName")
package dev.adriankuta.flights.ui.home.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import dev.adriankuta.flights.ui.home.StationsScreen
import kotlinx.serialization.Serializable
@Serializable
data object StationsRoute
fun NavController.navigateToStations(
navOptions: NavOptions,
) {
navigate(route = StationsRoute, navOptions = navOptions)
}
fun NavGraphBuilder.stationsScreen() {
composable<StationsRoute> {
StationsScreen()
}
}

View File

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