mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-07-01 21:28:00 +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:
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