feat: Implement airport data fetching and display

This commit introduces the functionality to fetch airport data from the Ryanair API and display it in the UI.

Key changes:
- Added `AirportInfoModel` interface and its implementation `AirportInfoModelImpl` to represent airport data in the data layer.
- Created `AirportInfoModelMapper` to map `AirportResponse` to `AirportInfoModel`.
- Set the base URL for Retrofit in `NetworkModule` to `https://services-api.ryanair.com`.
- Added new Gradle modules: `model:data:shared`, `model:datasource:airports`, `model:datasource:shared`, and `domain:search`.
- Implemented `CacheImpl` in `model:data:shared` for generic caching.
- Defined `ObserveAirportsUseCase` interface in `domain:search` to observe airport data.
- Added Detekt configuration for the `domain:search` and `model:datasource:shared` modules.
- Created `AirportsDatasource` interface and its implementation `AirportsDatasourceImpl` to manage airport data.
- Implemented `AirportInfoDomainMapper` to map `AirportInfoModel` to the domain `AirportInfo`.
- Updated `HomeScreen` to display a list of airports using `LazyColumn`.
- Updated `HomeScreenViewModel` to fetch and expose airport data via `ObserveAirportsUseCase`.
- Added `ObserveAirportsUseCaseModule` to provide the `ObserveAirportsUseCase` implementation.
- Implemented `ObserveAirportsUseCaseImpl` in the repository layer to fetch data from the API and update the datasource.
- Added `kotlinx-datetime` dependency.
- Created `AirportsDatasourceModule` to provide `AirportsDatasource` and its implementation.
- Added `CacheObservers.kt` with a utility function `loadData` to handle cache observation and data loading logic.
- Updated dependencies in `model:data:simple`, `model:repository`, and `ui:home` build files.
- Updated `settings.gradle.kts` to include new modules.
- Defined `Cache` interface in `model:datasource:shared`.
This commit is contained in:
2025-06-13 21:45:55 +02:00
parent 0fb82d57dd
commit fcc84dc728
26 changed files with 437 additions and 5 deletions

View File

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

View File

@ -0,0 +1,10 @@
# Deviations from defaults
formatting:
TrailingCommaOnCallSite:
active: true
autoCorrect: true
useTrailingCommaOnCallSite: true
TrailingCommaOnDeclarationSite:
active: true
autoCorrect: true
useTrailingCommaOnDeclarationSite: true

View File

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

View File

@ -18,6 +18,7 @@ appUpdateKtx = "2.1.0"
appcompat = "1.7.0"
converterMoshi = "2.11.0"
coreTest = "1.6.1" # https://developer.android.com/jetpack/androidx/releases/test
datetime = "0.6.1" # https://github.com/Kotlin/kotlinx-datetime/releases
detekt = "1.23.8" # https://detekt.dev/changelog
detektCompose = "0.4.22" # https://github.com/mrmans0n/compose-rules/releases
espressoCore = "3.6.1"
@ -84,6 +85,7 @@ kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.re
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "immutableCollections" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" }
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" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }

View File

@ -39,6 +39,7 @@ class NetworkModule {
@Provides
fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder()
// TODO("add URL provided in the task instructions")
.baseUrl("https://services-api.ryanair.com")
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()

View File

@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.flights.android.library)
alias(libs.plugins.flights.android.library.hilt)
}
android {
namespace = "dev.adriankuta.flights.model.data.shared"
}
dependencies {
implementation(projects.model.datasource.shared)
implementation(libs.timber)
}

View File

@ -0,0 +1,14 @@
package dev.adriankuta.flights.model.data.shared
import dev.adriankuta.flights.model.datasource.shared.Cache
public data class CacheImpl<T>(override val cacheKey: String?, override val data: T?) :
Cache<T> {
public companion object {
public inline fun <reified T> emptyCache(): CacheImpl<T> =
CacheImpl(null, null)
public inline fun <reified T> Cache<T>?.orEmpty(): Cache<T> =
this ?: emptyCache()
}
}

View File

@ -9,6 +9,9 @@ android {
dependencies {
implementation(projects.core.util)
implementation(projects.model.data.api)
implementation(projects.model.data.shared)
implementation(projects.model.datasource.airports)
implementation(libs.timber)
implementation(libs.gson)

View File

@ -0,0 +1,28 @@
package dev.adriankuta.flights.model.data.simple
import dev.adriankuta.flights.model.data.shared.CacheImpl
import dev.adriankuta.flights.model.data.simple.mappers.toModel
import dev.adriankuta.flights.model.datasource.airports.AirportsDatasource
import dev.adriankuta.flights.model.datasource.airports.entities.AirportInfoModel
import dev.adriankuta.flights.model.datasource.shared.Cache
import dev.adriankuta.model.data.api.entities.AirportResponse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
internal class AirportsDatasourceImpl : AirportsDatasource {
private val _airports = MutableStateFlow(CacheImpl.emptyCache<List<AirportInfoModel>>())
override val airports: StateFlow<Cache<List<AirportInfoModel>>> = _airports.asStateFlow()
override suspend fun setAirportsInfo(
airports: List<AirportResponse>,
cacheKey: String
) {
_airports.value = CacheImpl(
cacheKey = cacheKey,
data = airports.map { it.toModel() }
)
}
}

View File

@ -0,0 +1,30 @@
package dev.adriankuta.flights.model.data.simple.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.adriankuta.flights.model.data.simple.AirportsDatasourceImpl
import dev.adriankuta.flights.model.datasource.airports.AirportsDatasource
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@Suppress("UnnecessaryAbstractClass")
internal abstract class AirportsDatasourceModule {
@Binds
abstract fun bindApi(impl: AirportsDatasourceImpl): AirportsDatasource
/*@Binds
@IntoSet
abstract fun bindReset(impl: RolesDatasourceImpl): ClearDatasource*/
}
@Module
@InstallIn(SingletonComponent::class)
internal class AirportsDatasourceImplModule {
@Provides
@Singleton
fun provide(): AirportsDatasourceImpl = AirportsDatasourceImpl()
}

View File

@ -0,0 +1,42 @@
package dev.adriankuta.flights.model.data.simple.mappers
import dev.adriankuta.flights.model.datasource.airports.entities.AirportInfoModel
import dev.adriankuta.model.data.api.entities.AirportResponse
internal fun AirportResponse.toModel(): AirportInfoModel {
return AirportInfoModelImpl(
code = code.orEmpty(),
name = name.orEmpty(),
seoName = seoName.orEmpty(),
isBase = isBase ?: false,
timeZone = timeZone.orEmpty(),
cityCode = city?.code.orEmpty(),
cityName = city?.name.orEmpty(),
macCode = macCity?.macCode.orEmpty(),
regionCode = region?.code.orEmpty(),
regionName = region?.name.orEmpty(),
countryCode = country?.code.orEmpty(),
countryName = country?.name.orEmpty(),
countryCurrencyCode = country?.currencyCode.orEmpty(),
latitude = coordinates?.latitude ?: 0.0,
longitude = coordinates?.longitude ?: 0.0
)
}
private data class AirportInfoModelImpl(
override val code: String,
override val name: String,
override val seoName: String,
override val isBase: Boolean,
override val timeZone: String,
override val cityCode: String,
override val cityName: String,
override val macCode: String,
override val regionCode: String,
override val regionName: String,
override val countryCode: String,
override val countryName: String,
override val countryCurrencyCode: String,
override val latitude: Double,
override val longitude: Double
) : AirportInfoModel

View File

@ -0,0 +1,15 @@
plugins {
alias(libs.plugins.flights.android.library)
alias(libs.plugins.flights.android.library.hilt)
}
android {
namespace = "dev.adriankuta.flights.model.datasource.airports"
}
dependencies {
api(projects.model.datasource.shared)
implementation(projects.model.data.api)
implementation(libs.timber)
}

View File

@ -0,0 +1,13 @@
package dev.adriankuta.flights.model.datasource.airports
import dev.adriankuta.flights.model.datasource.airports.entities.AirportInfoModel
import dev.adriankuta.flights.model.datasource.shared.Cache
import dev.adriankuta.model.data.api.entities.AirportResponse
import kotlinx.coroutines.flow.StateFlow
interface AirportsDatasource {
val airports: StateFlow<Cache<List<AirportInfoModel>>>
suspend fun setAirportsInfo(airports: List<AirportResponse>, cacheKey: String)
}

View File

@ -0,0 +1,29 @@
package dev.adriankuta.flights.model.datasource.airports.entities
interface AirportInfoModel {
val code: String
val name: String
val seoName: String
val isBase: Boolean
val timeZone: String
// City properties
val cityCode: String
val cityName: String
// MacCity properties
val macCode: String
// Region properties
val regionCode: String
val regionName: String
// Country properties
val countryCode: String
val countryName: String
val countryCurrencyCode: String
// Coordinates properties
val latitude: Double
val longitude: Double
}

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.model.datasource.shared"
}
dependencies {
implementation(libs.timber)
}

View File

@ -0,0 +1,10 @@
# Deviations from defaults
formatting:
TrailingCommaOnCallSite:
active: true
autoCorrect: true
useTrailingCommaOnCallSite: true
TrailingCommaOnDeclarationSite:
active: true
autoCorrect: true
useTrailingCommaOnDeclarationSite: true

View File

@ -0,0 +1,6 @@
package dev.adriankuta.flights.model.datasource.shared;
public interface Cache<T> {
public val cacheKey: String?
public val data: T?
}

View File

@ -8,7 +8,12 @@ android {
}
dependencies {
implementation(libs.timber)
implementation(projects.domain.search)
implementation(projects.model.data.api)
implementation(projects.model.datasource.airports)
testImplementation("io.mockk:mockk:1.13.8")
implementation(libs.timber)
implementation(libs.kotlinx.datetime)
testImplementation(libs.mockk.android)
}

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.search.ObserveAirportsUseCase
import dev.adriankuta.flights.model.repository.usecases.ObserveAirportsUseCaseImpl
@Module
@InstallIn(SingletonComponent::class)
@Suppress("UnnecessaryAbstractClass")
internal abstract class ObserveAirportsUseCaseModule {
@Binds
abstract fun bind(
observeAirportsUseCaseImpl: ObserveAirportsUseCaseImpl
): ObserveAirportsUseCase
}

View File

@ -0,0 +1,39 @@
package dev.adriankuta.flights.model.repository.mappers
import dev.adriankuta.flights.domain.types.AirportInfo
import dev.adriankuta.flights.domain.types.City
import dev.adriankuta.flights.domain.types.Coordinates
import dev.adriankuta.flights.domain.types.Country
import dev.adriankuta.flights.domain.types.MacCity
import dev.adriankuta.flights.domain.types.Region
import dev.adriankuta.flights.model.datasource.airports.entities.AirportInfoModel
internal fun AirportInfoModel.toDomain(): AirportInfo {
return AirportInfo(
code = code,
name = name,
seoName = seoName,
isBase = isBase,
timeZone = timeZone,
city = City(
code = cityCode,
name = cityName
),
macCity = MacCity(
macCode = macCode
),
region = Region(
code = regionCode,
name = regionName
),
country = Country(
code = countryCode,
name = countryName,
currencyCode = countryCurrencyCode
),
coordinates = Coordinates(
latitude = latitude,
longitude = longitude
)
)
}

View File

@ -0,0 +1,29 @@
package dev.adriankuta.flights.model.repository.usecases
import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase
import dev.adriankuta.flights.domain.types.AirportInfo
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.flow.Flow
import javax.inject.Inject
internal class ObserveAirportsUseCaseImpl @Inject constructor(
private val airportService: AirportService,
private val airportsDatasource: AirportsDatasource,
) : ObserveAirportsUseCase {
override fun invoke(): Flow<List<AirportInfo>?> = loadData(
onCacheInvalidated = {
val response = airportService.getAirports("PL")
airportsDatasource.setAirportsInfo(response, "PL")
},
observeCache = {
airportsDatasource.airports
},
mapToDomain = {
it.orEmpty().map(AirportInfoModel::toDomain)
},
)
}

View File

@ -0,0 +1,21 @@
package dev.adriankuta.flights.model.repository.utilities
import dev.adriankuta.flights.model.datasource.shared.Cache
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
internal fun <T, R> loadData(
onCacheInvalidated: suspend (cacheKey: String) -> Unit,
observeCache: () -> Flow<Cache<T>>,
mapToDomain: (T?) -> R?,
): Flow<R?> {
return observeCache().distinctUntilChanged().map {
if (it.cacheKey == null) {
val refreshTime = Clock.System.now()
onCacheInvalidated(it.cacheKey ?: refreshTime.toEpochMilliseconds().toString())
}
mapToDomain(it.data)
}
}

View File

@ -24,11 +24,14 @@ rootProject.name = "Flights"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
include(":app")
include(":core:util")
include(":domain:search")
include(":domain:types")
include(":model:data:api")
include(":model:data:room")
include(":model:data:shared")
include(":model:data:simple")
include(":model:datasource:characters")
include(":model:datasource:airports")
include(":model:datasource:shared")
include(":model:repository")
include(":ui:designsystem")
include(":ui:home")

View File

@ -10,6 +10,7 @@ android {
dependencies {
implementation(projects.ui.designsystem)
implementation(projects.domain.search)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.timber)

View File

@ -1,7 +1,14 @@
package dev.adriankuta.flights.ui.home
import androidx.compose.foundation.lazy.LazyColumn
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.ui.designsystem.theme.FlightsTheme
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
@ -9,12 +16,31 @@ import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
internal fun HomeScreen(
viewModel: HomeScreenViewModel = hiltViewModel(),
) {
val homeUiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
airports = homeUiState.airports
)
}
@Composable
private fun HomeScreen(
airports: List<AirportInfo>,
modifier: Modifier = Modifier,
) {
LazyColumn {
items(airports) { airport ->
Text(airport.name)
}
}
}
@PreviewDevices
@Composable
private fun HomeScreenPreview() {
FlightsTheme {
HomeScreen()
HomeScreen(
airports = listOf()
)
}
}

View File

@ -1,8 +1,47 @@
package dev.adriankuta.flights.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase
import dev.adriankuta.flights.domain.types.AirportInfo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
class HomeScreenViewModel @Inject constructor() : ViewModel()
class HomeScreenViewModel @Inject constructor(
private val observeAirportsUseCase: ObserveAirportsUseCase
) : ViewModel() {
internal val uiState = homeUiState(
useCase = observeAirportsUseCase
)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = HomeUiState(),
)
}
private fun homeUiState(
useCase: ObserveAirportsUseCase
): Flow<HomeUiState> {
return flow {
delay(15.seconds)
emit("")
}.flatMapLatest { useCase() }.map {
HomeUiState(it.orEmpty())
}
}
internal data class HomeUiState(
val airports: List<AirportInfo> = emptyList()
)