feat: Introduce Result type and update UI states

This commit introduces a generic `Result` sealed interface to represent success, error, and loading states for asynchronous operations. It also updates the home screen UI and ViewModel to utilize this `Result` type for displaying different states.

Key changes:
- Added `Result.kt` in `core:util` defining the `Result` sealed interface and an `asResult()` Flow extension.
- Updated `HomeScreenViewModel` to use `asResult()` and map the `ObserveAirportsUseCase` output to `HomeUiState` (Loading, Success, Error).
- Modified `HomeUiState` to be a sealed interface with `Loading`, `Success`, and `Error` subtypes.
- Updated `HomeScreen` to handle different `HomeUiState` values and display appropriate UI (Loading text, Error text, or list of airports).
- Added `core.util` dependency to `ui:home`.
- Updated Moshi and Retrofit Moshi converter dependencies in `model/data/api/build.gradle.kts` and `gradle/libs.versions.toml`.
- Added `moshi` and `moshiKotlinCodegen` versions to `gradle/libs.versions.toml`.
- Removed `converter-moshi` and added `retrofit-converter-moshi` in `gradle/libs.versions.toml`.
- Added `ksp(libs.moshi.kotlin.codegen)` to `model/data/api/build.gradle.kts`.
- Added `INTERNET` permission to `app/src/main/AndroidManifest.xml`.
- Updated `ObserveAirportsUseCaseImpl` to use `cacheKey` when setting airports info and to fetch airports for "pl" (lowercase).
- Added `.editorconfig` file with Kotlin trailing comma settings.
This commit is contained in:
2025-06-13 22:12:05 +02:00
parent 9e18987aaf
commit a7f8ca75be
9 changed files with 63 additions and 26 deletions

4
.editorconfig Executable file
View File

@ -0,0 +1,4 @@
[*.{kt,kts}]
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true

View File

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".MyApplication" android:name=".MyApplication"
android:allowBackup="true" android:allowBackup="true"

View File

@ -0,0 +1,16 @@
package dev.adriankuta.flights.core.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }

View File

@ -33,6 +33,8 @@ kotlinxSerializationJson = "1.8.1"
ksp = "2.1.21-2.0.1" # https://github.com/google/ksp/releases ksp = "2.1.21-2.0.1" # https://github.com/google/ksp/releases
material = "1.12.0" material = "1.12.0"
mockk = "1.14.2" # https://github.com/mockk/mockk/releases mockk = "1.14.2" # https://github.com/mockk/mockk/releases
moshi = "1.15.2"
moshiKotlinCodegen = "1.15.2"
okhttpBom = "4.12.0" okhttpBom = "4.12.0"
retrofit = "3.0.0" retrofit = "3.0.0"
secrets = "2.0.1" secrets = "2.0.1"
@ -73,7 +75,6 @@ androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator",
androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "uiTextGoogleFonts" }
app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtx" } app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "appUpdateKtx" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" }
detekt-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" } detekt-compose = { module = "io.nlopez.compose.rules:detekt", version.ref = "detektCompose" }
detekt-ktlint = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } detekt-ktlint = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
@ -89,10 +90,13 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshiKotlinCodegen" }
okhttp = { module = "com.squareup.okhttp3:okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp" }
okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" } okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "converterMoshi" }
timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" }
truth = { module = "com.google.truth:truth", version.ref = "truth" } truth = { module = "com.google.truth:truth", version.ref = "truth" }

View File

@ -12,10 +12,12 @@ dependencies {
implementation(platform(libs.okhttp.bom)) implementation(platform(libs.okhttp.bom))
implementation(libs.converter.moshi) implementation(libs.moshi)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor) implementation(libs.okhttp.logging.interceptor)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.converter.moshi)
ksp(libs.moshi.kotlin.codegen)
implementation(libs.timber) implementation(libs.timber)
implementation(libs.gson) implementation(libs.gson)

View File

@ -15,9 +15,9 @@ internal class ObserveAirportsUseCaseImpl @Inject constructor(
private val airportsDatasource: AirportsDatasource, private val airportsDatasource: AirportsDatasource,
) : ObserveAirportsUseCase { ) : ObserveAirportsUseCase {
override fun invoke(): Flow<List<AirportInfo>?> = loadData( override fun invoke(): Flow<List<AirportInfo>?> = loadData(
onCacheInvalidated = { onCacheInvalidated = { cacheKey ->
val response = airportService.getAirports("PL") val response = airportService.getAirports("pl")
airportsDatasource.setAirportsInfo(response, "PL") airportsDatasource.setAirportsInfo(response, cacheKey)
}, },
observeCache = { observeCache = {
airportsDatasource.airports airportsDatasource.airports

View File

@ -9,6 +9,7 @@ android {
} }
dependencies { dependencies {
implementation(projects.core.util)
implementation(projects.ui.designsystem) implementation(projects.ui.designsystem)
implementation(projects.domain.search) implementation(projects.domain.search)

View File

@ -8,7 +8,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.adriankuta.flights.domain.types.AirportInfo
import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
@ -19,28 +18,34 @@ internal fun HomeScreen(
val homeUiState by viewModel.uiState.collectAsStateWithLifecycle() val homeUiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen( HomeScreen(
airports = homeUiState.airports uiState = homeUiState,
) )
} }
@Composable @Composable
private fun HomeScreen( private fun HomeScreen(
airports: List<AirportInfo>, uiState: HomeUiState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn { LazyColumn(
items(airports) { airport -> modifier = modifier,
) {
when (uiState) {
is HomeUiState.Error -> item { Text("Error") }
HomeUiState.Loading -> item { Text("Loading") }
is HomeUiState.Success -> items(uiState.airports) { airport ->
Text(airport.name) Text(airport.name)
} }
} }
} }
}
@PreviewDevices @PreviewDevices
@Composable @Composable
private fun HomeScreenPreview() { private fun HomeScreenPreview() {
FlightsTheme { FlightsTheme {
HomeScreen( HomeScreen(
airports = listOf() uiState = HomeUiState.Loading,
) )
} }
} }

View File

@ -3,17 +3,15 @@ package dev.adriankuta.flights.ui.home
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.search.ObserveAirportsUseCase import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase
import dev.adriankuta.flights.domain.types.AirportInfo import dev.adriankuta.flights.domain.types.AirportInfo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class HomeScreenViewModel @Inject constructor( class HomeScreenViewModel @Inject constructor(
@ -26,7 +24,7 @@ class HomeScreenViewModel @Inject constructor(
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUiState(), initialValue = HomeUiState.Loading,
) )
} }
@ -34,14 +32,19 @@ private fun homeUiState(
useCase: ObserveAirportsUseCase useCase: ObserveAirportsUseCase
): Flow<HomeUiState> { ): Flow<HomeUiState> {
return flow { return useCase()
delay(15.seconds) .asResult()
emit("") .map { result ->
}.flatMapLatest { useCase() }.map { when (result) {
HomeUiState(it.orEmpty()) is Result.Error -> HomeUiState.Error(result.exception)
Result.Loading -> HomeUiState.Loading
is Result.Success -> HomeUiState.Success(result.data.orEmpty())
}
} }
} }
internal data class HomeUiState( internal sealed interface HomeUiState {
val airports: List<AirportInfo> = emptyList() data object Loading : HomeUiState
) data class Success(val airports: List<AirportInfo>) : HomeUiState
data class Error(val exception: Throwable) : HomeUiState
}