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

@ -60,6 +60,7 @@ dependencies {
implementation(projects.ui.designsystem)
implementation(projects.ui.home)
implementation(projects.ui.stations)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen)

View File

@ -12,6 +12,8 @@ import androidx.navigation.navOptions
import dev.adriankuta.flights.ui.home.navigation.HomeRoute
import dev.adriankuta.flights.ui.home.navigation.homeScreen
import dev.adriankuta.flights.ui.home.navigation.navigateToHome
import dev.adriankuta.flights.ui.home.navigation.navigateToStations
import dev.adriankuta.flights.ui.home.navigation.stationsScreen
@Composable
fun FlightsNavGraph(
@ -24,6 +26,7 @@ fun FlightsNavGraph(
modifier = modifier,
) {
homeScreen()
stationsScreen()
}
}
@ -45,7 +48,7 @@ fun NavController.navigateToTopLevelDestination(topLevelDestination: TopLevelDes
when (topLevelDestination) {
TopLevelDestination.HOME -> navigateToHome(topLevelNavOptions)
TopLevelDestination.STATIONS -> navigateToHome(topLevelNavOptions)
TopLevelDestination.STATIONS -> navigateToStations(topLevelNavOptions)
}
}
}

View File

@ -6,8 +6,10 @@ import androidx.compose.material.icons.outlined.Place
import androidx.compose.material.icons.outlined.Search
import androidx.compose.ui.graphics.vector.ImageVector
import dev.adriankuta.flights.ui.home.navigation.HomeRoute
import dev.adriankuta.flights.ui.home.navigation.StationsRoute
import kotlin.reflect.KClass
import dev.adriankuta.flights.ui.home.R as homeR
import dev.adriankuta.flights.ui.stations.R as stationsR
enum class TopLevelDestination(
val icon: ImageVector,
@ -22,7 +24,7 @@ enum class TopLevelDestination(
),
STATIONS(
icon = Icons.Outlined.Place,
titleTextId = homeR.string.home_screen_title,
route = HomeRoute::class,
titleTextId = stationsR.string.stations_screen_title,
route = StationsRoute::class,
),
}

View File

@ -60,7 +60,7 @@ internal fun FlightsBottomBar(
selected = selectedDestination == index,
onClick = {
selectedDestination = index
navController.navigateToTopLevelDestination(TopLevelDestination.HOME)
navController.navigateToTopLevelDestination(destination)
},
icon = {
Icon(

View File

@ -0,0 +1,12 @@
plugins {
alias(libs.plugins.flights.android.library)
alias(libs.plugins.flights.android.library.hilt)
}
android {
namespace = "dev.adriankuta.flights.domain.stations"
}
dependencies {
api(projects.domain.types)
}

View File

@ -0,0 +1,9 @@
package dev.adriankuta.flights.domain.stations
import dev.adriankuta.flights.domain.types.AirportInfo
import dev.adriankuta.flights.domain.types.Country
import kotlinx.coroutines.flow.Flow
fun interface ObserveAirportsGroupedByCountry {
operator fun invoke(): Flow<Map<Country, List<AirportInfo>>?>
}

View File

@ -8,7 +8,9 @@ android {
}
dependencies {
implementation(projects.core.util)
implementation(projects.domain.search)
implementation(projects.domain.stations)
implementation(projects.model.data.api)
implementation(projects.model.datasource.airports)

View File

@ -0,0 +1,19 @@
package dev.adriankuta.flights.model.repository.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.adriankuta.flights.domain.stations.ObserveAirportsGroupedByCountry
import dev.adriankuta.flights.model.repository.usecases.ObserveAirportsGroupedByCountryImpl
@Module
@InstallIn(SingletonComponent::class)
@Suppress("UnnecessaryAbstractClass")
internal abstract class ObserveAirportsGroupedByCountryModule {
@Binds
abstract fun bind(
observeAirportsGroupedByCountryImpl: ObserveAirportsGroupedByCountryImpl,
): ObserveAirportsGroupedByCountry
}

View File

@ -0,0 +1,38 @@
package dev.adriankuta.flights.model.repository.usecases
import dev.adriankuta.flights.core.util.DefaultDispatcher
import dev.adriankuta.flights.domain.stations.ObserveAirportsGroupedByCountry
import dev.adriankuta.flights.domain.types.AirportInfo
import dev.adriankuta.flights.domain.types.Country
import dev.adriankuta.flights.model.datasource.airports.AirportsDatasource
import dev.adriankuta.flights.model.datasource.airports.entities.AirportInfoModel
import dev.adriankuta.flights.model.repository.mappers.toDomain
import dev.adriankuta.flights.model.repository.utilities.loadData
import dev.adriankuta.model.data.api.AirportService
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class ObserveAirportsGroupedByCountryImpl @Inject constructor(
private val airportService: AirportService,
private val airportsDatasource: AirportsDatasource,
@DefaultDispatcher private val dispatcher: CoroutineDispatcher,
) : ObserveAirportsGroupedByCountry {
override fun invoke(): Flow<Map<Country, List<AirportInfo>>?> = loadData(
onCacheInvalidated = { cacheKey ->
val response = airportService.getAirports("pl")
airportsDatasource.setAirportsInfo(response, cacheKey)
},
observeCache = {
airportsDatasource.airports
},
mapToDomain = { model ->
withContext(dispatcher) {
model.orEmpty().map(AirportInfoModel::toDomain).groupBy { airportInfo ->
airportInfo.country
}.toSortedMap(compareBy { it.name })
}
},
)
}

View File

@ -9,7 +9,7 @@ import kotlinx.datetime.Clock
internal fun <T, R> loadData(
onCacheInvalidated: suspend (cacheKey: String) -> Unit,
observeCache: () -> Flow<Cache<T>>,
mapToDomain: (T?) -> R?,
mapToDomain: suspend (T?) -> R?,
): Flow<R?> {
return observeCache().distinctUntilChanged().map {
if (it.cacheKey == null) {

View File

@ -25,6 +25,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(":app")
include(":core:util")
include(":domain:search")
include(":domain:stations")
include(":domain:types")
include(":model:data:api")
include(":model:data:room")
@ -36,3 +37,4 @@ include(":model:repository")
include(":ui:designsystem")
include(":ui:home")
include(":ui:sharedui")
include(":ui:stations")

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>