mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-07-01 10:07:58 +02:00
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:
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
@ -60,7 +60,7 @@ internal fun FlightsBottomBar(
|
||||
selected = selectedDestination == index,
|
||||
onClick = {
|
||||
selectedDestination = index
|
||||
navController.navigateToTopLevelDestination(TopLevelDestination.HOME)
|
||||
navController.navigateToTopLevelDestination(destination)
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
|
12
domain/stations/build.gradle.kts
Normal file
12
domain/stations/build.gradle.kts
Normal 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)
|
||||
}
|
@ -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>>?>
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -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) {
|
||||
|
@ -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")
|
||||
|
18
ui/stations/build.gradle.kts
Normal file
18
ui/stations/build.gradle.kts
Normal 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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
4
ui/stations/src/main/res/values/strings.xml
Normal file
4
ui/stations/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="stations_screen_title">Stations</string>
|
||||
</resources>
|
Reference in New Issue
Block a user