diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a660f9e..4432bc2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/kotlin/dev/adriankuta/flights/navigation/FlightsNavGraph.kt b/app/src/main/kotlin/dev/adriankuta/flights/navigation/FlightsNavGraph.kt index 15a9e9e..7c243e2 100644 --- a/app/src/main/kotlin/dev/adriankuta/flights/navigation/FlightsNavGraph.kt +++ b/app/src/main/kotlin/dev/adriankuta/flights/navigation/FlightsNavGraph.kt @@ -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) } } } diff --git a/app/src/main/kotlin/dev/adriankuta/flights/navigation/TopLevelDestination.kt b/app/src/main/kotlin/dev/adriankuta/flights/navigation/TopLevelDestination.kt index 84156d2..a0b45ec 100644 --- a/app/src/main/kotlin/dev/adriankuta/flights/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/dev/adriankuta/flights/navigation/TopLevelDestination.kt @@ -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, ), } diff --git a/app/src/main/kotlin/dev/adriankuta/flights/ui/FlightsApp.kt b/app/src/main/kotlin/dev/adriankuta/flights/ui/FlightsApp.kt index f149476..b585777 100644 --- a/app/src/main/kotlin/dev/adriankuta/flights/ui/FlightsApp.kt +++ b/app/src/main/kotlin/dev/adriankuta/flights/ui/FlightsApp.kt @@ -60,7 +60,7 @@ internal fun FlightsBottomBar( selected = selectedDestination == index, onClick = { selectedDestination = index - navController.navigateToTopLevelDestination(TopLevelDestination.HOME) + navController.navigateToTopLevelDestination(destination) }, icon = { Icon( diff --git a/domain/stations/build.gradle.kts b/domain/stations/build.gradle.kts new file mode 100644 index 0000000..1de2326 --- /dev/null +++ b/domain/stations/build.gradle.kts @@ -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) +} diff --git a/domain/stations/src/main/kotlin/dev/adriankuta/flights/domain/stations/ObserveAirportsGroupedByCountry.kt b/domain/stations/src/main/kotlin/dev/adriankuta/flights/domain/stations/ObserveAirportsGroupedByCountry.kt new file mode 100644 index 0000000..659bd53 --- /dev/null +++ b/domain/stations/src/main/kotlin/dev/adriankuta/flights/domain/stations/ObserveAirportsGroupedByCountry.kt @@ -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>?> +} diff --git a/model/repository/build.gradle.kts b/model/repository/build.gradle.kts index ea7838f..163b9c4 100644 --- a/model/repository/build.gradle.kts +++ b/model/repository/build.gradle.kts @@ -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) diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/di/ObserveAirportsGroupedByCountryModule.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/di/ObserveAirportsGroupedByCountryModule.kt new file mode 100644 index 0000000..87e6692 --- /dev/null +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/di/ObserveAirportsGroupedByCountryModule.kt @@ -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 +} diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/ObserveAirportsGroupedByCountryImpl.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/ObserveAirportsGroupedByCountryImpl.kt new file mode 100644 index 0000000..eb91cba --- /dev/null +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/ObserveAirportsGroupedByCountryImpl.kt @@ -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>?> = 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 }) + } + }, + ) +} diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/utilities/CacheObservers.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/utilities/CacheObservers.kt index b614f9e..e7b3f87 100644 --- a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/utilities/CacheObservers.kt +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/utilities/CacheObservers.kt @@ -9,7 +9,7 @@ import kotlinx.datetime.Clock internal fun loadData( onCacheInvalidated: suspend (cacheKey: String) -> Unit, observeCache: () -> Flow>, - mapToDomain: (T?) -> R?, + mapToDomain: suspend (T?) -> R?, ): Flow { return observeCache().distinctUntilChanged().map { if (it.cacheKey == null) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 47d4459..3624705 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/ui/stations/build.gradle.kts b/ui/stations/build.gradle.kts new file mode 100644 index 0000000..055875e --- /dev/null +++ b/ui/stations/build.gradle.kts @@ -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) +} 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 new file mode 100644 index 0000000..886635b --- /dev/null +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt @@ -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>, +) { + data.keys.forEach { country -> + stickyHeader { CountryItem(country) } + items(data[country]!!) { airport -> + AirportInfoItem(airport) + } + } +} 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 new file mode 100644 index 0000000..154833a --- /dev/null +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt @@ -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 { + 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>) : OriginAirportsUiState + data class Error(val exception: Throwable) : OriginAirportsUiState +} + +internal sealed interface DestinationAirportsUiState { + data object Loading : DestinationAirportsUiState + data class Success(val airports: List) : DestinationAirportsUiState + data class Error(val exception: Throwable) : DestinationAirportsUiState +} 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 new file mode 100644 index 0000000..ccedddc --- /dev/null +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/AirportInfoItem.kt @@ -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, + ) + } + } +} diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/CountryItem.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/CountryItem.kt new file mode 100644 index 0000000..7a5fc2c --- /dev/null +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/components/CountryItem.kt @@ -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, + ) + } + } + } +} diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/navigation/StationsNavigation.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/navigation/StationsNavigation.kt new file mode 100644 index 0000000..c6edcee --- /dev/null +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/navigation/StationsNavigation.kt @@ -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 { + StationsScreen() + } +} diff --git a/ui/stations/src/main/res/values/strings.xml b/ui/stations/src/main/res/values/strings.xml new file mode 100644 index 0000000..f8639e5 --- /dev/null +++ b/ui/stations/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Stations + \ No newline at end of file