mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-09-14 21:04:22 +02:00
Compare commits
6 Commits
ffcfc1f45b
...
8ce553240c
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8ce553240c | ||
![]() |
e8ac7c5596 | ||
![]() |
504f798bd3 | ||
![]() |
3e9768919d | ||
![]() |
13348bc52f | ||
![]() |
762c6338de |
@@ -1,7 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.serialization)
|
|
||||||
alias(libs.plugins.flights.android.application.compose)
|
alias(libs.plugins.flights.android.application.compose)
|
||||||
alias(libs.plugins.flights.android.application.hilt)
|
alias(libs.plugins.flights.android.application.hilt)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -60,6 +60,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation(projects.ui.designsystem)
|
implementation(projects.ui.designsystem)
|
||||||
implementation(projects.ui.home)
|
implementation(projects.ui.home)
|
||||||
|
implementation(projects.ui.stations)
|
||||||
|
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
@@ -1,23 +0,0 @@
|
|||||||
package dev.adriankuta.flights
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.navigation.NavHostController
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import dev.adriankuta.flights.ui.home.navigation.HomeRoute
|
|
||||||
import dev.adriankuta.flights.ui.home.navigation.homeScreen
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FlightsNavGraph(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
navController: NavHostController = rememberNavController(),
|
|
||||||
) {
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = HomeRoute,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
homeScreen()
|
|
||||||
}
|
|
||||||
}
|
|
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.adriankuta.flights.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.util.trace
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
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(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
navController: NavHostController = rememberNavController(),
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = HomeRoute,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
homeScreen()
|
||||||
|
stationsScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavController.navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
|
||||||
|
trace("Navigation: ${topLevelDestination.name}") {
|
||||||
|
val topLevelNavOptions = navOptions {
|
||||||
|
// Pop up to the start destination of the graph to
|
||||||
|
// avoid building up a large stack of destinations
|
||||||
|
// on the back stack as users select items
|
||||||
|
popUpTo(graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
// Avoid multiple copies of the same destination when
|
||||||
|
// reselecting the same item
|
||||||
|
launchSingleTop = true
|
||||||
|
// Restore state when reselecting a previously selected item
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
|
||||||
|
when (topLevelDestination) {
|
||||||
|
TopLevelDestination.HOME -> navigateToHome(topLevelNavOptions)
|
||||||
|
TopLevelDestination.STATIONS -> navigateToStations(topLevelNavOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
package dev.adriankuta.flights.navigation
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
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,
|
||||||
|
@StringRes val titleTextId: Int,
|
||||||
|
val route: KClass<*>,
|
||||||
|
val baseRoute: KClass<*> = route,
|
||||||
|
) {
|
||||||
|
HOME(
|
||||||
|
icon = Icons.Outlined.Search,
|
||||||
|
titleTextId = homeR.string.home_screen_title,
|
||||||
|
route = HomeRoute::class,
|
||||||
|
),
|
||||||
|
STATIONS(
|
||||||
|
icon = Icons.Outlined.Place,
|
||||||
|
titleTextId = stationsR.string.stations_screen_title,
|
||||||
|
route = StationsRoute::class,
|
||||||
|
),
|
||||||
|
}
|
@@ -1,25 +1,75 @@
|
|||||||
package dev.adriankuta.flights.ui
|
package dev.adriankuta.flights.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarDefaults
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dev.adriankuta.flights.FlightsNavGraph
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import dev.adriankuta.flights.navigation.FlightsNavGraph
|
||||||
|
import dev.adriankuta.flights.navigation.TopLevelDestination
|
||||||
|
import dev.adriankuta.flights.navigation.navigateToTopLevelDestination
|
||||||
import dev.adriankuta.flights.ui.designsystem.theme.Elevation
|
import dev.adriankuta.flights.ui.designsystem.theme.Elevation
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FlightsApp(
|
fun FlightsApp(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
tonalElevation = Elevation.Surface,
|
tonalElevation = Elevation.Surface,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { InAppUpdates() },
|
snackbarHost = { InAppUpdates() },
|
||||||
|
bottomBar = {
|
||||||
|
FlightsBottomBar(
|
||||||
|
navController = navController,
|
||||||
|
)
|
||||||
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
FlightsNavGraph(Modifier.padding(paddingValues))
|
FlightsNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
modifier = Modifier.padding(paddingValues),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun FlightsBottomBar(
|
||||||
|
navController: NavHostController = rememberNavController(),
|
||||||
|
) {
|
||||||
|
var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
NavigationBar(windowInsets = NavigationBarDefaults.windowInsets) {
|
||||||
|
TopLevelDestination.entries.forEachIndexed { index, destination ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedDestination == index,
|
||||||
|
onClick = {
|
||||||
|
selectedDestination = index
|
||||||
|
navController.navigateToTopLevelDestination(destination)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
destination.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(destination.titleTextId)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -61,6 +61,7 @@ internal fun Project.configureKotlinAndroid(
|
|||||||
dependencies {
|
dependencies {
|
||||||
"implementation"(libs.findLibrary("androidx.core.ktx").get())
|
"implementation"(libs.findLibrary("androidx.core.ktx").get())
|
||||||
"implementation"(libs.findLibrary("kotlinx.coroutines.android").get())
|
"implementation"(libs.findLibrary("kotlinx.coroutines.android").get())
|
||||||
|
"implementation"(libs.findLibrary("kotlinx.datetime").get())
|
||||||
"implementation"(libs.findLibrary("timber").get())
|
"implementation"(libs.findLibrary("timber").get())
|
||||||
|
|
||||||
"coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())
|
"coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())
|
@@ -9,5 +9,4 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.domain.types)
|
api(projects.domain.types)
|
||||||
implementation(libs.timber)
|
}
|
||||||
}
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
package dev.adriankuta.flights.domain.search.entities
|
package dev.adriankuta.flights.domain.search.entities
|
||||||
|
|
||||||
import dev.adriankuta.flights.domain.types.Airport
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
import java.time.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
data class SearchOptions(
|
data class SearchOptions(
|
||||||
val origin: Airport.Departure,
|
val origin: Airport.Departure,
|
||||||
|
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)
|
||||||
|
}
|
10
domain/stations/config/detekt/detekt.yml
Normal file
10
domain/stations/config/detekt/detekt.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Deviations from defaults
|
||||||
|
formatting:
|
||||||
|
TrailingCommaOnCallSite:
|
||||||
|
active: true
|
||||||
|
autoCorrect: true
|
||||||
|
useTrailingCommaOnCallSite: true
|
||||||
|
TrailingCommaOnDeclarationSite:
|
||||||
|
active: true
|
||||||
|
autoCorrect: true
|
||||||
|
useTrailingCommaOnDeclarationSite: true
|
11
domain/stations/lint-baseline.xml
Normal file
11
domain/stations/lint-baseline.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<issues format="6" by="lint 8.10.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.10.1)" variant="all" version="8.10.1">
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="Aligned16KB"
|
||||||
|
message="The native library `arm64-v8a/libmockkjvmtiagent.so` (from `io.mockk:mockk-agent-android:1.14.2`) is not 16 KB aligned">
|
||||||
|
<location
|
||||||
|
file="$GRADLE_USER_HOME/caches/8.11.1/transforms/60089ed8c197d152569cb75de3649edc/transformed/mockk-agent-android-1.14.2/jni/arm64-v8a/libmockkjvmtiagent.so"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
</issues>
|
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.adriankuta.flights.domain.stations
|
||||||
|
|
||||||
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
|
|
||||||
|
fun interface GetConnectionsForAirportUseCase {
|
||||||
|
|
||||||
|
suspend operator fun invoke(airport: Airport): Set<Airport>
|
||||||
|
}
|
@@ -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>>?>
|
||||||
|
}
|
@@ -9,5 +9,4 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.core.util)
|
implementation(projects.core.util)
|
||||||
implementation(libs.timber)
|
}
|
||||||
}
|
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
package dev.adriankuta.flights.domain.types
|
package dev.adriankuta.flights.domain.types
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
data class TripDate(
|
data class TripDate(
|
||||||
val dateOut: String,
|
val dateOut: LocalDate?,
|
||||||
val flights: List<TripFlight>,
|
val flights: List<TripFlight>,
|
||||||
)
|
)
|
||||||
|
@@ -8,7 +8,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(projects.core.util)
|
||||||
implementation(projects.domain.search)
|
implementation(projects.domain.search)
|
||||||
|
implementation(projects.domain.stations)
|
||||||
implementation(projects.model.data.api)
|
implementation(projects.model.data.api)
|
||||||
implementation(projects.model.datasource.airports)
|
implementation(projects.model.datasource.airports)
|
||||||
|
|
||||||
@@ -16,4 +18,5 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
testImplementation(libs.mockk.android)
|
testImplementation(libs.mockk.android)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
|
@@ -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.GetConnectionsForAirportUseCase
|
||||||
|
import dev.adriankuta.flights.model.repository.usecases.GetConnectionsForAirportUseCaseImpl
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
@Suppress("UnnecessaryAbstractClass")
|
||||||
|
internal abstract class GetConnectionsForAirportUseCaseModule {
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
abstract fun bind(
|
||||||
|
getConnectionsForAirportUseCaseImpl: GetConnectionsForAirportUseCaseImpl,
|
||||||
|
): GetConnectionsForAirportUseCase
|
||||||
|
}
|
@@ -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,35 @@
|
|||||||
|
package dev.adriankuta.flights.model.repository.mappers
|
||||||
|
|
||||||
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
|
import dev.adriankuta.flights.domain.types.MacCity
|
||||||
|
import dev.adriankuta.model.data.api.entities.RouteResponse
|
||||||
|
|
||||||
|
fun RouteResponse.Airport.MacCity.toDomain(): MacCity? {
|
||||||
|
val macCode = macCode ?: return null
|
||||||
|
return MacCity(macCode = macCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun RouteResponse.toDomain(): List<Airport> {
|
||||||
|
val departureAirportDomain = departureAirport?.let {
|
||||||
|
Airport.Departure(
|
||||||
|
code = it.code ?: return@let null,
|
||||||
|
name = it.name ?: return@let null,
|
||||||
|
macCity = departureAirport?.macCity?.toDomain(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val arrivalAirportDomain = arrivalAirport?.let {
|
||||||
|
Airport.Arrival(
|
||||||
|
code = it.code ?: return@let null,
|
||||||
|
name = it.name ?: return@let null,
|
||||||
|
macCity = arrivalAirport?.macCity?.toDomain(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val connectingAirportDomain = connectingAirport?.let {
|
||||||
|
Airport.Connecting(
|
||||||
|
code = it.code ?: return@let null,
|
||||||
|
name = it.name ?: return@let null,
|
||||||
|
macCity = connectingAirport?.macCity?.toDomain(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return listOfNotNull(departureAirportDomain, arrivalAirportDomain, connectingAirportDomain)
|
||||||
|
}
|
@@ -8,6 +8,7 @@ import dev.adriankuta.flights.domain.types.TripDate
|
|||||||
import dev.adriankuta.flights.domain.types.TripFare
|
import dev.adriankuta.flights.domain.types.TripFare
|
||||||
import dev.adriankuta.flights.domain.types.TripFlight
|
import dev.adriankuta.flights.domain.types.TripFlight
|
||||||
import dev.adriankuta.model.data.api.entities.FlightResponse
|
import dev.adriankuta.model.data.api.entities.FlightResponse
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
import dev.adriankuta.model.data.api.entities.RegularFare as ApiRegularFare
|
import dev.adriankuta.model.data.api.entities.RegularFare as ApiRegularFare
|
||||||
import dev.adriankuta.model.data.api.entities.Segment as ApiSegment
|
import dev.adriankuta.model.data.api.entities.Segment as ApiSegment
|
||||||
import dev.adriankuta.model.data.api.entities.Trip as ApiTrip
|
import dev.adriankuta.model.data.api.entities.Trip as ApiTrip
|
||||||
@@ -33,7 +34,7 @@ internal fun ApiTrip.toDomain(): Trip {
|
|||||||
|
|
||||||
internal fun ApiTripDate.toDomain(): TripDate {
|
internal fun ApiTripDate.toDomain(): TripDate {
|
||||||
return TripDate(
|
return TripDate(
|
||||||
dateOut = dateOut ?: "",
|
dateOut = dateOut?.let { LocalDateTime.parse(it).date },
|
||||||
flights = flights.orEmpty().map { it.toDomain() },
|
flights = flights.orEmpty().map { it.toDomain() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.adriankuta.flights.model.repository.usecases
|
||||||
|
|
||||||
|
import dev.adriankuta.flights.domain.stations.GetConnectionsForAirportUseCase
|
||||||
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
|
import dev.adriankuta.flights.model.repository.mappers.toDomain
|
||||||
|
import dev.adriankuta.model.data.api.RoutesService
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
internal class GetConnectionsForAirportUseCaseImpl @Inject constructor(
|
||||||
|
private val routesService: RoutesService,
|
||||||
|
) : GetConnectionsForAirportUseCase {
|
||||||
|
override suspend fun invoke(airport: Airport): Set<Airport> {
|
||||||
|
val result = routesService.getRoutes(
|
||||||
|
languageCode = "pl",
|
||||||
|
departureAirportCode = airport.code,
|
||||||
|
)
|
||||||
|
return result.map { it.toDomain() }.flatten().toSet()
|
||||||
|
}
|
||||||
|
}
|
@@ -5,7 +5,6 @@ import dev.adriankuta.flights.domain.search.entities.SearchOptions
|
|||||||
import dev.adriankuta.flights.domain.types.Flight
|
import dev.adriankuta.flights.domain.types.Flight
|
||||||
import dev.adriankuta.flights.model.repository.mappers.toDomain
|
import dev.adriankuta.flights.model.repository.mappers.toDomain
|
||||||
import dev.adriankuta.model.data.api.FlightService
|
import dev.adriankuta.model.data.api.FlightService
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
|
internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
|
||||||
@@ -16,7 +15,7 @@ internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
|
|||||||
val result = flightService.getFlights(
|
val result = flightService.getFlights(
|
||||||
origin = searchOptions.origin.code,
|
origin = searchOptions.origin.code,
|
||||||
destination = searchOptions.destination.code,
|
destination = searchOptions.destination.code,
|
||||||
date = searchOptions.date.format(DateTimeFormatter.ISO_DATE),
|
date = searchOptions.date.toString(),
|
||||||
adult = searchOptions.adults,
|
adult = searchOptions.adults,
|
||||||
teen = searchOptions.teens,
|
teen = searchOptions.teens,
|
||||||
child = searchOptions.children,
|
child = searchOptions.children,
|
||||||
|
@@ -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(
|
internal fun <T, R> loadData(
|
||||||
onCacheInvalidated: suspend (cacheKey: String) -> Unit,
|
onCacheInvalidated: suspend (cacheKey: String) -> Unit,
|
||||||
observeCache: () -> Flow<Cache<T>>,
|
observeCache: () -> Flow<Cache<T>>,
|
||||||
mapToDomain: (T?) -> R?,
|
mapToDomain: suspend (T?) -> R?,
|
||||||
): Flow<R?> {
|
): Flow<R?> {
|
||||||
return observeCache().distinctUntilChanged().map {
|
return observeCache().distinctUntilChanged().map {
|
||||||
if (it.cacheKey == null) {
|
if (it.cacheKey == null) {
|
||||||
|
@@ -0,0 +1,215 @@
|
|||||||
|
package dev.adriankuta.flights.model.repository.usecases
|
||||||
|
|
||||||
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
|
import dev.adriankuta.flights.domain.types.MacCity
|
||||||
|
import dev.adriankuta.model.data.api.RoutesService
|
||||||
|
import dev.adriankuta.model.data.api.entities.RouteResponse
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class GetConnectionsForAirportUseCaseImplTest {
|
||||||
|
|
||||||
|
private lateinit var routesService: RoutesService
|
||||||
|
private lateinit var useCase: GetConnectionsForAirportUseCaseImpl
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
routesService = mockk()
|
||||||
|
useCase = GetConnectionsForAirportUseCaseImpl(routesService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoke returns mapped airports when service returns valid response`() = runTest {
|
||||||
|
// Given
|
||||||
|
val departureAirport = Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val routeResponse = RouteResponse(
|
||||||
|
departureAirport = RouteResponse.Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
arrivalAirport = RouteResponse.Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
connectingAirport = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
routesService.getRoutes(
|
||||||
|
languageCode = "pl",
|
||||||
|
departureAirportCode = "WAW",
|
||||||
|
)
|
||||||
|
} returns listOf(routeResponse)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke(departureAirport)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val expectedAirports = setOf(
|
||||||
|
Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedAirports, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoke returns empty set when service returns empty list`() = runTest {
|
||||||
|
// Given
|
||||||
|
val departureAirport = Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
routesService.getRoutes(
|
||||||
|
languageCode = "pl",
|
||||||
|
departureAirportCode = "WAW",
|
||||||
|
)
|
||||||
|
} returns emptyList()
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke(departureAirport)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(emptySet<Airport>(), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoke filters out airports with null code or name`() = runTest {
|
||||||
|
// Given
|
||||||
|
val departureAirport = Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val routeResponse = RouteResponse(
|
||||||
|
departureAirport = RouteResponse.Airport.Departure(
|
||||||
|
code = null, // This should be filtered out
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
arrivalAirport = RouteResponse.Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
connectingAirport = RouteResponse.Airport.Connecting(
|
||||||
|
code = "BER",
|
||||||
|
name = null, // This should be filtered out
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "BER"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
routesService.getRoutes(
|
||||||
|
languageCode = "pl",
|
||||||
|
departureAirportCode = "WAW",
|
||||||
|
)
|
||||||
|
} returns listOf(routeResponse)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke(departureAirport)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val expectedAirports = setOf(
|
||||||
|
Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedAirports, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoke handles multiple route responses correctly`() = runTest {
|
||||||
|
// Given
|
||||||
|
val departureAirport = Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val routeResponse1 = RouteResponse(
|
||||||
|
departureAirport = RouteResponse.Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
arrivalAirport = RouteResponse.Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
connectingAirport = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val routeResponse2 = RouteResponse(
|
||||||
|
departureAirport = RouteResponse.Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
arrivalAirport = RouteResponse.Airport.Arrival(
|
||||||
|
code = "PAR",
|
||||||
|
name = "Paris",
|
||||||
|
macCity = RouteResponse.Airport.MacCity(macCode = "PAR"),
|
||||||
|
),
|
||||||
|
connectingAirport = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
routesService.getRoutes(
|
||||||
|
languageCode = "pl",
|
||||||
|
departureAirportCode = "WAW",
|
||||||
|
)
|
||||||
|
} returns listOf(routeResponse1, routeResponse2)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke(departureAirport)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val expectedAirports = setOf(
|
||||||
|
Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
Airport.Arrival(
|
||||||
|
code = "PAR",
|
||||||
|
name = "Paris",
|
||||||
|
macCity = MacCity(macCode = "PAR"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedAirports, result)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,220 @@
|
|||||||
|
package dev.adriankuta.flights.model.repository.usecases
|
||||||
|
|
||||||
|
import dev.adriankuta.flights.domain.search.entities.SearchOptions
|
||||||
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
|
import dev.adriankuta.flights.domain.types.Flight
|
||||||
|
import dev.adriankuta.flights.domain.types.MacCity
|
||||||
|
import dev.adriankuta.flights.domain.types.RegularFare
|
||||||
|
import dev.adriankuta.flights.domain.types.Segment
|
||||||
|
import dev.adriankuta.flights.domain.types.Trip
|
||||||
|
import dev.adriankuta.flights.domain.types.TripDate
|
||||||
|
import dev.adriankuta.flights.domain.types.TripFare
|
||||||
|
import dev.adriankuta.flights.domain.types.TripFlight
|
||||||
|
import dev.adriankuta.model.data.api.FlightService
|
||||||
|
import dev.adriankuta.model.data.api.entities.FlightResponse
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class GetFlightsSearchContentUseCaseImplTest {
|
||||||
|
|
||||||
|
private lateinit var flightService: FlightService
|
||||||
|
private lateinit var useCase: GetFlightsSearchContentUseCaseImpl
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
flightService = mockk()
|
||||||
|
useCase = GetFlightsSearchContentUseCaseImpl(flightService)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
fun `invoke returns mapped flight when service returns valid response`() = runTest {
|
||||||
|
// Given
|
||||||
|
val searchOptions = SearchOptions(
|
||||||
|
origin = Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
destination = Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
date = LocalDate(2023, 10, 15),
|
||||||
|
adults = 1,
|
||||||
|
teens = 0,
|
||||||
|
children = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
val flightResponse = FlightResponse(
|
||||||
|
currency = "EUR",
|
||||||
|
currPrecision = 2,
|
||||||
|
trips = listOf(
|
||||||
|
dev.adriankuta.model.data.api.entities.Trip(
|
||||||
|
dates = listOf(
|
||||||
|
dev.adriankuta.model.data.api.entities.TripDate(
|
||||||
|
dateOut = "2023-10-15T00:00:00",
|
||||||
|
flights = listOf(
|
||||||
|
dev.adriankuta.model.data.api.entities.TripFlight(
|
||||||
|
faresLeft = 10,
|
||||||
|
regularFare = dev.adriankuta.model.data.api.entities.RegularFare(
|
||||||
|
fares = listOf(
|
||||||
|
dev.adriankuta.model.data.api.entities.TripFare(
|
||||||
|
type = "ADT",
|
||||||
|
amount = 99.99,
|
||||||
|
count = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-10-15T10:00:00",
|
||||||
|
"2023-10-15T12:00:00",
|
||||||
|
),
|
||||||
|
duration = "2:00",
|
||||||
|
segments = listOf(
|
||||||
|
dev.adriankuta.model.data.api.entities.Segment(
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-10-15T10:00:00",
|
||||||
|
"2023-10-15T12:00:00",
|
||||||
|
),
|
||||||
|
duration = "2:00",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
operatedBy = "Ryanair",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
flightService.getFlights(
|
||||||
|
date = "2023-10-15",
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
adult = 1,
|
||||||
|
teen = 0,
|
||||||
|
child = 0,
|
||||||
|
)
|
||||||
|
} returns flightResponse
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke(searchOptions)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val expectedFlight = Flight(
|
||||||
|
currency = "EUR",
|
||||||
|
currPrecision = 2,
|
||||||
|
trips = listOf(
|
||||||
|
Trip(
|
||||||
|
dates = listOf(
|
||||||
|
TripDate(
|
||||||
|
dateOut = LocalDate(2023, 10, 15),
|
||||||
|
flights = listOf(
|
||||||
|
TripFlight(
|
||||||
|
faresLeft = 10,
|
||||||
|
regularFare = RegularFare(
|
||||||
|
fares = listOf(
|
||||||
|
TripFare(
|
||||||
|
type = "ADT",
|
||||||
|
amount = 99.99,
|
||||||
|
count = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-10-15T10:00:00",
|
||||||
|
"2023-10-15T12:00:00",
|
||||||
|
),
|
||||||
|
duration = "2:00",
|
||||||
|
segments = listOf(
|
||||||
|
Segment(
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-10-15T10:00:00",
|
||||||
|
"2023-10-15T12:00:00",
|
||||||
|
),
|
||||||
|
duration = "2:00",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
operatedBy = "Ryanair",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedFlight, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoke handles null values in response correctly`() = runTest {
|
||||||
|
// Given
|
||||||
|
val searchOptions = SearchOptions(
|
||||||
|
origin = Airport.Departure(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
macCity = MacCity(macCode = "WAW"),
|
||||||
|
),
|
||||||
|
destination = Airport.Arrival(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
macCity = MacCity(macCode = "LDN"),
|
||||||
|
),
|
||||||
|
date = LocalDate(2023, 10, 15),
|
||||||
|
adults = 1,
|
||||||
|
teens = 0,
|
||||||
|
children = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
val flightResponse = FlightResponse(
|
||||||
|
currency = null,
|
||||||
|
currPrecision = null,
|
||||||
|
trips = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery {
|
||||||
|
flightService.getFlights(
|
||||||
|
date = "2023-10-15",
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
adult = 1,
|
||||||
|
teen = 0,
|
||||||
|
child = 0,
|
||||||
|
)
|
||||||
|
} returns flightResponse
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke(searchOptions)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val expectedFlight = Flight(
|
||||||
|
currency = "",
|
||||||
|
currPrecision = 0,
|
||||||
|
trips = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedFlight, result)
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,335 @@
|
|||||||
|
package dev.adriankuta.flights.model.repository.usecases
|
||||||
|
|
||||||
|
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.AirportsDatasource
|
||||||
|
import dev.adriankuta.flights.model.datasource.airports.entities.AirportInfoModel
|
||||||
|
import dev.adriankuta.flights.model.datasource.shared.Cache
|
||||||
|
import dev.adriankuta.model.data.api.AirportService
|
||||||
|
import dev.adriankuta.model.data.api.entities.AirportResponse
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ObserveAirportsUseCaseImplTest {
|
||||||
|
|
||||||
|
private lateinit var airportService: AirportService
|
||||||
|
private lateinit var airportsDatasource: AirportsDatasource
|
||||||
|
private lateinit var useCase: ObserveAirportsUseCaseImpl
|
||||||
|
private lateinit var airportsFlow: MutableStateFlow<Cache<List<AirportInfoModel>>>
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
airportService = mockk()
|
||||||
|
airportsDatasource = mockk()
|
||||||
|
airportsFlow = MutableStateFlow(TestCache(null, null))
|
||||||
|
|
||||||
|
every { airportsDatasource.airports } returns airportsFlow
|
||||||
|
|
||||||
|
useCase = ObserveAirportsUseCaseImpl(
|
||||||
|
airportService = airportService,
|
||||||
|
airportsDatasource = airportsDatasource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
fun `invoke returns mapped airports when datasource returns valid data`() = runTest {
|
||||||
|
// Given
|
||||||
|
val airportInfoModel1 = TestAirportInfoModel(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw Chopin",
|
||||||
|
seoName = "Warsaw",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
cityCode = "WAW",
|
||||||
|
cityName = "Warsaw",
|
||||||
|
macCode = "WAW",
|
||||||
|
regionCode = "PL-MZ",
|
||||||
|
regionName = "Mazovia",
|
||||||
|
countryCode = "PL",
|
||||||
|
countryName = "Poland",
|
||||||
|
countryCurrencyCode = "PLN",
|
||||||
|
latitude = 52.1657,
|
||||||
|
longitude = 20.9671,
|
||||||
|
)
|
||||||
|
|
||||||
|
val airportInfoModel2 = TestAirportInfoModel(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London Heathrow",
|
||||||
|
seoName = "London",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/London",
|
||||||
|
cityCode = "LDN",
|
||||||
|
cityName = "London",
|
||||||
|
macCode = "LDN",
|
||||||
|
regionCode = "GB-LDN",
|
||||||
|
regionName = "London",
|
||||||
|
countryCode = "GB",
|
||||||
|
countryName = "United Kingdom",
|
||||||
|
countryCurrencyCode = "GBP",
|
||||||
|
latitude = 51.4700,
|
||||||
|
longitude = -0.4543,
|
||||||
|
)
|
||||||
|
|
||||||
|
AirportResponse(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw Chopin",
|
||||||
|
seoName = "Warsaw",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
city = AirportResponse.City(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
),
|
||||||
|
macCity = AirportResponse.MacCity(
|
||||||
|
code = "WAW",
|
||||||
|
macCode = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
),
|
||||||
|
region = AirportResponse.Region(
|
||||||
|
code = "PL-MZ",
|
||||||
|
name = "Mazovia",
|
||||||
|
),
|
||||||
|
country = AirportResponse.Country(
|
||||||
|
code = "PL",
|
||||||
|
name = "Poland",
|
||||||
|
currencyCode = "PLN",
|
||||||
|
),
|
||||||
|
coordinates = AirportResponse.Coordinates(
|
||||||
|
latitude = 52.1657,
|
||||||
|
longitude = 20.9671,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
airportsFlow.value = TestCache("test-key", listOf(airportInfoModel1, airportInfoModel2))
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke().first()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
val expectedAirports = listOf(
|
||||||
|
AirportInfo(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw Chopin",
|
||||||
|
seoName = "Warsaw",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
city = City(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
),
|
||||||
|
macCity = MacCity(
|
||||||
|
macCode = "WAW",
|
||||||
|
),
|
||||||
|
region = Region(
|
||||||
|
code = "PL-MZ",
|
||||||
|
name = "Mazovia",
|
||||||
|
),
|
||||||
|
country = Country(
|
||||||
|
code = "PL",
|
||||||
|
name = "Poland",
|
||||||
|
currencyCode = "PLN",
|
||||||
|
),
|
||||||
|
coordinates = Coordinates(
|
||||||
|
latitude = 52.1657,
|
||||||
|
longitude = 20.9671,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AirportInfo(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London Heathrow",
|
||||||
|
seoName = "London",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/London",
|
||||||
|
city = City(
|
||||||
|
code = "LDN",
|
||||||
|
name = "London",
|
||||||
|
),
|
||||||
|
macCity = MacCity(
|
||||||
|
macCode = "LDN",
|
||||||
|
),
|
||||||
|
region = Region(
|
||||||
|
code = "GB-LDN",
|
||||||
|
name = "London",
|
||||||
|
),
|
||||||
|
country = Country(
|
||||||
|
code = "GB",
|
||||||
|
name = "United Kingdom",
|
||||||
|
currencyCode = "GBP",
|
||||||
|
),
|
||||||
|
coordinates = Coordinates(
|
||||||
|
latitude = 51.4700,
|
||||||
|
longitude = -0.4543,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(expectedAirports, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoke returns empty list when datasource returns empty list`() = runTest {
|
||||||
|
// Given
|
||||||
|
airportsFlow.value = TestCache("test-key", emptyList())
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke().first()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(emptyList<AirportInfo>(), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invoke returns empty list when datasource returns null`() = runTest {
|
||||||
|
// Given
|
||||||
|
airportsFlow.value = TestCache("test-key", null)
|
||||||
|
|
||||||
|
// When
|
||||||
|
val result = useCase.invoke().first()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(emptyList<AirportInfo>(), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
fun `invoke triggers cache refresh when cache key is null`() = runTest {
|
||||||
|
// Given
|
||||||
|
val airportResponse = listOf(
|
||||||
|
AirportResponse(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw Chopin",
|
||||||
|
seoName = "Warsaw",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
city = AirportResponse.City(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
),
|
||||||
|
macCity = AirportResponse.MacCity(
|
||||||
|
code = "WAW",
|
||||||
|
macCode = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
),
|
||||||
|
region = AirportResponse.Region(
|
||||||
|
code = "PL-MZ",
|
||||||
|
name = "Mazovia",
|
||||||
|
),
|
||||||
|
country = AirportResponse.Country(
|
||||||
|
code = "PL",
|
||||||
|
name = "Poland",
|
||||||
|
currencyCode = "PLN",
|
||||||
|
),
|
||||||
|
coordinates = AirportResponse.Coordinates(
|
||||||
|
latitude = 52.1657,
|
||||||
|
longitude = 20.9671,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create a test model that will be returned after cache refresh
|
||||||
|
val testModel = TestAirportInfoModel(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw Chopin",
|
||||||
|
seoName = "Warsaw",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
cityCode = "WAW",
|
||||||
|
cityName = "Warsaw",
|
||||||
|
macCode = "WAW",
|
||||||
|
regionCode = "PL-MZ",
|
||||||
|
regionName = "Mazovia",
|
||||||
|
countryCode = "PL",
|
||||||
|
countryName = "Poland",
|
||||||
|
countryCurrencyCode = "PLN",
|
||||||
|
latitude = 52.1657,
|
||||||
|
longitude = 20.9671,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start with null cache key to trigger refresh
|
||||||
|
airportsFlow.value = TestCache(null, null)
|
||||||
|
|
||||||
|
// Set up mocks
|
||||||
|
coEvery { airportService.getAirports("pl") } returns airportResponse
|
||||||
|
coEvery { airportsDatasource.setAirportsInfo(any(), any()) } answers {
|
||||||
|
// Update the flow with new data when setAirportsInfo is called
|
||||||
|
airportsFlow.value = TestCache("new-cache-key", listOf(testModel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the expected result
|
||||||
|
val expectedAirport = AirportInfo(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw Chopin",
|
||||||
|
seoName = "Warsaw",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
city = City(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw",
|
||||||
|
),
|
||||||
|
macCity = MacCity(
|
||||||
|
macCode = "WAW",
|
||||||
|
),
|
||||||
|
region = Region(
|
||||||
|
code = "PL-MZ",
|
||||||
|
name = "Mazovia",
|
||||||
|
),
|
||||||
|
country = Country(
|
||||||
|
code = "PL",
|
||||||
|
name = "Poland",
|
||||||
|
currencyCode = "PLN",
|
||||||
|
),
|
||||||
|
coordinates = Coordinates(
|
||||||
|
latitude = 52.1657,
|
||||||
|
longitude = 20.9671,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// When - collect from the flow and wait for the first emission
|
||||||
|
val flow = useCase.invoke()
|
||||||
|
|
||||||
|
// Manually update the flow to simulate cache refresh
|
||||||
|
// This ensures the flow has a value before we collect it
|
||||||
|
airportsFlow.value = TestCache("new-cache-key", listOf(testModel))
|
||||||
|
|
||||||
|
// Now collect the first value
|
||||||
|
val result = flow.first()
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertEquals(listOf(expectedAirport), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TestCache<T>(
|
||||||
|
override val cacheKey: String?,
|
||||||
|
override val data: T?,
|
||||||
|
) : Cache<T>
|
||||||
|
|
||||||
|
private data class TestAirportInfoModel(
|
||||||
|
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
|
||||||
|
}
|
@@ -25,6 +25,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
|||||||
include(":app")
|
include(":app")
|
||||||
include(":core:util")
|
include(":core:util")
|
||||||
include(":domain:search")
|
include(":domain:search")
|
||||||
|
include(":domain:stations")
|
||||||
include(":domain:types")
|
include(":domain:types")
|
||||||
include(":model:data:api")
|
include(":model:data:api")
|
||||||
include(":model:data:room")
|
include(":model:data:room")
|
||||||
@@ -36,3 +37,4 @@ include(":model:repository")
|
|||||||
include(":ui:designsystem")
|
include(":ui:designsystem")
|
||||||
include(":ui:home")
|
include(":ui:home")
|
||||||
include(":ui:sharedui")
|
include(":ui:sharedui")
|
||||||
|
include(":ui:stations")
|
||||||
|
@@ -15,5 +15,4 @@ dependencies {
|
|||||||
implementation(projects.domain.search)
|
implementation(projects.domain.search)
|
||||||
|
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
implementation(libs.timber)
|
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
package dev.adriankuta.flights.ui.home
|
package dev.adriankuta.flights.ui.home
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -7,29 +8,39 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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.domain.types.AirportInfo
|
||||||
import dev.adriankuta.flights.domain.types.City
|
import dev.adriankuta.flights.domain.types.Flight
|
||||||
import dev.adriankuta.flights.domain.types.Coordinates
|
import dev.adriankuta.flights.domain.types.RegularFare
|
||||||
import dev.adriankuta.flights.domain.types.Country
|
import dev.adriankuta.flights.domain.types.Segment
|
||||||
import dev.adriankuta.flights.domain.types.MacCity
|
import dev.adriankuta.flights.domain.types.Trip
|
||||||
import dev.adriankuta.flights.domain.types.Region
|
import dev.adriankuta.flights.domain.types.TripDate
|
||||||
|
import dev.adriankuta.flights.domain.types.TripFare
|
||||||
|
import dev.adriankuta.flights.domain.types.TripFlight
|
||||||
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
|
||||||
import dev.adriankuta.flights.ui.home.components.SearchForm
|
import dev.adriankuta.flights.ui.home.components.SearchForm
|
||||||
import java.time.LocalDate
|
import dev.adriankuta.flights.ui.home.components.SearchResults
|
||||||
|
import dev.adriankuta.flights.ui.home.sample.HomeUiStatePreviewParameterProvider
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun HomeScreen(
|
internal fun HomeScreen(
|
||||||
viewModel: HomeScreenViewModel = hiltViewModel(),
|
viewModel: HomeScreenViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val homeUiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val homeUiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val searchResults by viewModel.searchResultUiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
uiState = homeUiState,
|
uiState = homeUiState,
|
||||||
|
searchResultsUiState = searchResults,
|
||||||
onOriginAirportSelect = viewModel::selectOriginAirport,
|
onOriginAirportSelect = viewModel::selectOriginAirport,
|
||||||
onDestinationAirportSelect = viewModel::selectDestinationAirport,
|
onDestinationAirportSelect = viewModel::selectDestinationAirport,
|
||||||
onDateSelect = viewModel::selectDate,
|
onDateSelect = viewModel::selectDate,
|
||||||
@@ -43,6 +54,7 @@ internal fun HomeScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun HomeScreen(
|
private fun HomeScreen(
|
||||||
uiState: HomeUiState,
|
uiState: HomeUiState,
|
||||||
|
searchResultsUiState: SearchResultUiState,
|
||||||
onOriginAirportSelect: (AirportInfo) -> Unit,
|
onOriginAirportSelect: (AirportInfo) -> Unit,
|
||||||
onDestinationAirportSelect: (AirportInfo) -> Unit,
|
onDestinationAirportSelect: (AirportInfo) -> Unit,
|
||||||
onDateSelect: (LocalDate) -> Unit,
|
onDateSelect: (LocalDate) -> Unit,
|
||||||
@@ -52,27 +64,39 @@ private fun HomeScreen(
|
|||||||
onSearch: () -> Unit,
|
onSearch: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
var showSearchResults by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
BackHandler(enabled = showSearchResults) {
|
||||||
|
showSearchResults = false
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(16.dp)
|
.padding(16.dp),
|
||||||
.verticalScroll(
|
|
||||||
state = scrollState,
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
when (uiState) {
|
when (uiState) {
|
||||||
is HomeUiState.Error -> Text("Error")
|
is HomeUiState.Error -> Text("Error")
|
||||||
HomeUiState.Loading -> Text("Loading")
|
HomeUiState.Loading -> Text("Loading")
|
||||||
is HomeUiState.Success -> SearchForm(
|
is HomeUiState.Success -> if (showSearchResults) {
|
||||||
uiState = uiState,
|
SearchResults(
|
||||||
onOriginAirportSelect = onOriginAirportSelect,
|
uiState = searchResultsUiState,
|
||||||
onDestinationAirportSelect = onDestinationAirportSelect,
|
)
|
||||||
onDateSelect = onDateSelect,
|
} else {
|
||||||
onAdultCountChange = onAdultCountChange,
|
SearchForm(
|
||||||
onTeenCountChange = onTeenCountChange,
|
uiState = uiState,
|
||||||
onChildCountChange = onChildCountChange,
|
onOriginAirportSelect = onOriginAirportSelect,
|
||||||
onSearch = onSearch,
|
onDestinationAirportSelect = onDestinationAirportSelect,
|
||||||
)
|
onDateSelect = onDateSelect,
|
||||||
|
onAdultCountChange = onAdultCountChange,
|
||||||
|
onTeenCountChange = onTeenCountChange,
|
||||||
|
onChildCountChange = onChildCountChange,
|
||||||
|
onSearch = {
|
||||||
|
showSearchResults = true
|
||||||
|
onSearch()
|
||||||
|
},
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +107,7 @@ private fun HomeScreenLoadingPreview() {
|
|||||||
FlightsTheme {
|
FlightsTheme {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
uiState = HomeUiState.Loading,
|
uiState = HomeUiState.Loading,
|
||||||
|
searchResultsUiState = SearchResultUiState.Idle,
|
||||||
onOriginAirportSelect = {},
|
onOriginAirportSelect = {},
|
||||||
onDestinationAirportSelect = {},
|
onDestinationAirportSelect = {},
|
||||||
onDateSelect = {},
|
onDateSelect = {},
|
||||||
@@ -96,48 +121,83 @@ private fun HomeScreenLoadingPreview() {
|
|||||||
|
|
||||||
@PreviewDevices
|
@PreviewDevices
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeScreenSuccessPreview() {
|
private fun HomeScreenSuccessPreview(
|
||||||
|
@PreviewParameter(HomeUiStatePreviewParameterProvider::class) homeUiState: HomeUiState,
|
||||||
|
) {
|
||||||
FlightsTheme {
|
FlightsTheme {
|
||||||
val mockAirports = listOf(
|
|
||||||
AirportInfo(
|
|
||||||
code = "WAW",
|
|
||||||
name = "Warsaw Chopin Airport",
|
|
||||||
seoName = "warsaw",
|
|
||||||
isBase = true,
|
|
||||||
timeZone = "Europe/Warsaw",
|
|
||||||
city = City("WAW", "Warsaw"),
|
|
||||||
macCity = MacCity("WARSAW"),
|
|
||||||
region = Region("WARSAW_PL", "Warsaw"),
|
|
||||||
country = Country("PL", "Poland", "PLN"),
|
|
||||||
coordinates = Coordinates(52.1657, 20.9671),
|
|
||||||
),
|
|
||||||
AirportInfo(
|
|
||||||
code = "KRK",
|
|
||||||
name = "Krakow Airport",
|
|
||||||
seoName = "krakow",
|
|
||||||
isBase = true,
|
|
||||||
timeZone = "Europe/Warsaw",
|
|
||||||
city = City("KRK", "Krakow"),
|
|
||||||
macCity = MacCity("KRAKOW"),
|
|
||||||
region = Region("KRAKOW_PL", "Krakow"),
|
|
||||||
country = Country("PL", "Poland", "PLN"),
|
|
||||||
coordinates = Coordinates(50.0777, 19.7848),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
uiState = HomeUiState.Success(
|
uiState = homeUiState,
|
||||||
originAirports = mockAirports,
|
searchResultsUiState = SearchResultUiState.Idle,
|
||||||
destinationAirports = mockAirports,
|
onOriginAirportSelect = {},
|
||||||
selectedOriginAirport = mockAirports.first(),
|
onDestinationAirportSelect = {},
|
||||||
selectedDestinationAirport = mockAirports.last(),
|
onDateSelect = {},
|
||||||
selectedDate = LocalDate.now(),
|
onAdultCountChange = {},
|
||||||
passengers = PassengersState(
|
onTeenCountChange = {},
|
||||||
adultCount = 2,
|
onChildCountChange = {},
|
||||||
teenCount = 1,
|
onSearch = {},
|
||||||
childCount = 1,
|
)
|
||||||
),
|
}
|
||||||
),
|
}
|
||||||
|
|
||||||
|
@PreviewDevices
|
||||||
|
@Composable
|
||||||
|
@Suppress("LongMethod")
|
||||||
|
private fun HomeScreenSearchResultsPreview(
|
||||||
|
@PreviewParameter(HomeUiStatePreviewParameterProvider::class) homeUiState: HomeUiState,
|
||||||
|
) {
|
||||||
|
val mockFlight = Flight(
|
||||||
|
currency = "PLN",
|
||||||
|
currPrecision = 2,
|
||||||
|
trips = listOf<Trip>(
|
||||||
|
Trip(
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "KRK",
|
||||||
|
dates = listOf<TripDate>(
|
||||||
|
TripDate(
|
||||||
|
dateOut = LocalDate(2023, 6, 15),
|
||||||
|
flights = listOf(
|
||||||
|
TripFlight(
|
||||||
|
faresLeft = 10,
|
||||||
|
regularFare = RegularFare(
|
||||||
|
fares = listOf(
|
||||||
|
TripFare(
|
||||||
|
type = "ADT",
|
||||||
|
amount = 150.0,
|
||||||
|
count = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-15T10:00:00",
|
||||||
|
"2023-06-15T11:30:00",
|
||||||
|
),
|
||||||
|
duration = "1h 30m",
|
||||||
|
segments = listOf(
|
||||||
|
Segment(
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "KRK",
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-15T10:00:00",
|
||||||
|
"2023-06-15T11:30:00",
|
||||||
|
),
|
||||||
|
duration = "1h 30m",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
operatedBy = "Ryanair",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
FlightsTheme {
|
||||||
|
HomeScreen(
|
||||||
|
uiState = homeUiState,
|
||||||
|
searchResultsUiState = SearchResultUiState.Success(mockFlight),
|
||||||
onOriginAirportSelect = {},
|
onOriginAirportSelect = {},
|
||||||
onDestinationAirportSelect = {},
|
onDestinationAirportSelect = {},
|
||||||
onDateSelect = {},
|
onDateSelect = {},
|
||||||
|
@@ -10,6 +10,7 @@ import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase
|
|||||||
import dev.adriankuta.flights.domain.search.entities.SearchOptions
|
import dev.adriankuta.flights.domain.search.entities.SearchOptions
|
||||||
import dev.adriankuta.flights.domain.types.Airport
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
import dev.adriankuta.flights.domain.types.AirportInfo
|
import dev.adriankuta.flights.domain.types.AirportInfo
|
||||||
|
import dev.adriankuta.flights.domain.types.Flight
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -17,8 +18,10 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import kotlinx.datetime.Clock
|
||||||
import java.time.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.todayIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -29,10 +32,12 @@ class HomeScreenViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
||||||
private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(null)
|
private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(null)
|
||||||
private val selectedDate = MutableStateFlow(LocalDate.now())
|
private val selectedDate =
|
||||||
|
MutableStateFlow(Clock.System.todayIn(TimeZone.currentSystemDefault()))
|
||||||
private val adultCount = MutableStateFlow(1)
|
private val adultCount = MutableStateFlow(1)
|
||||||
private val teenCount = MutableStateFlow(0)
|
private val teenCount = MutableStateFlow(0)
|
||||||
private val childCount = MutableStateFlow(0)
|
private val childCount = MutableStateFlow(0)
|
||||||
|
private val searchResults = MutableStateFlow<SearchResultUiState>(SearchResultUiState.Idle)
|
||||||
|
|
||||||
internal val uiState = homeUiState(
|
internal val uiState = homeUiState(
|
||||||
useCase = observeAirportsUseCase,
|
useCase = observeAirportsUseCase,
|
||||||
@@ -51,6 +56,13 @@ class HomeScreenViewModel @Inject constructor(
|
|||||||
initialValue = HomeUiState.Loading,
|
initialValue = HomeUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal val searchResultUiState = searchResults
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = SearchResultUiState.Idle,
|
||||||
|
)
|
||||||
|
|
||||||
fun selectOriginAirport(airport: AirportInfo) {
|
fun selectOriginAirport(airport: AirportInfo) {
|
||||||
selectedOriginAirport.value = airport
|
selectedOriginAirport.value = airport
|
||||||
// If the selected destination is the same as the new origin, clear the destination
|
// If the selected destination is the same as the new origin, clear the destination
|
||||||
@@ -91,28 +103,40 @@ class HomeScreenViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun search() {
|
fun search() {
|
||||||
|
searchResults.value = SearchResultUiState.Loading
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val results = getFlightsSearchContentUseCase(
|
try {
|
||||||
searchOptions = SearchOptions(
|
val results = getFlightsSearchContentUseCase(
|
||||||
origin = Airport.Departure(
|
searchOptions = prepareSearchOptions(),
|
||||||
code = selectedOriginAirport.value?.code ?: return@launch,
|
)
|
||||||
name = selectedOriginAirport.value?.name ?: return@launch,
|
searchResults.value = SearchResultUiState.Success(results)
|
||||||
macCity = selectedOriginAirport.value?.macCity,
|
} catch (e: IllegalArgumentException) {
|
||||||
),
|
searchResults.value = SearchResultUiState.Error(e)
|
||||||
destination = Airport.Arrival(
|
}
|
||||||
code = selectedDestinationAirport.value?.code ?: return@launch,
|
|
||||||
name = selectedDestinationAirport.value?.name ?: return@launch,
|
|
||||||
macCity = selectedDestinationAirport.value?.macCity,
|
|
||||||
),
|
|
||||||
date = selectedDate.value,
|
|
||||||
adults = adultCount.value,
|
|
||||||
teens = teenCount.value,
|
|
||||||
children = childCount.value,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Timber.d("Result $results")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun prepareSearchOptions(): SearchOptions {
|
||||||
|
val selectedOrigin = requireNotNull(selectedOriginAirport.value)
|
||||||
|
val selectedDestination = requireNotNull(selectedDestinationAirport.value)
|
||||||
|
|
||||||
|
return SearchOptions(
|
||||||
|
origin = Airport.Departure(
|
||||||
|
code = selectedOrigin.code,
|
||||||
|
name = selectedOrigin.name,
|
||||||
|
macCity = selectedOrigin.macCity,
|
||||||
|
),
|
||||||
|
destination = Airport.Arrival(
|
||||||
|
code = selectedDestination.code,
|
||||||
|
name = selectedDestination.name,
|
||||||
|
macCity = selectedDestination.macCity,
|
||||||
|
),
|
||||||
|
date = selectedDate.value,
|
||||||
|
adults = adultCount.value,
|
||||||
|
teens = teenCount.value,
|
||||||
|
children = childCount.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun homeUiState(
|
private fun homeUiState(
|
||||||
@@ -172,13 +196,20 @@ internal sealed interface HomeUiState {
|
|||||||
val destinationAirports: List<AirportInfo>,
|
val destinationAirports: List<AirportInfo>,
|
||||||
val selectedOriginAirport: AirportInfo? = null,
|
val selectedOriginAirport: AirportInfo? = null,
|
||||||
val selectedDestinationAirport: AirportInfo? = null,
|
val selectedDestinationAirport: AirportInfo? = null,
|
||||||
val selectedDate: LocalDate = LocalDate.now(),
|
val selectedDate: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||||
val passengers: PassengersState,
|
val passengers: PassengersState,
|
||||||
) : HomeUiState
|
) : HomeUiState
|
||||||
|
|
||||||
data class Error(val exception: Throwable) : HomeUiState
|
data class Error(val exception: Throwable) : HomeUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed interface SearchResultUiState {
|
||||||
|
data object Loading : SearchResultUiState
|
||||||
|
data class Success(val flight: Flight) : SearchResultUiState
|
||||||
|
data class Error(val exception: Throwable) : SearchResultUiState
|
||||||
|
data object Idle : SearchResultUiState
|
||||||
|
}
|
||||||
|
|
||||||
internal data class PassengersState(
|
internal data class PassengersState(
|
||||||
val adultCount: Int,
|
val adultCount: Int,
|
||||||
val teenCount: Int,
|
val teenCount: Int,
|
||||||
|
@@ -24,10 +24,16 @@ import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
|||||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import java.time.Instant
|
import kotlinx.datetime.Clock
|
||||||
import java.time.LocalDate
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import java.time.ZoneId
|
import kotlinx.datetime.Instant
|
||||||
import java.time.ZoneOffset
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlinx.datetime.todayIn
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -45,8 +51,8 @@ fun DatePicker(
|
|||||||
|
|
||||||
// Ensure the selected date is not in the past
|
// Ensure the selected date is not in the past
|
||||||
val validatedDate = remember(selectedDate) {
|
val validatedDate = remember(selectedDate) {
|
||||||
if (selectedDate.isBefore(LocalDate.now())) {
|
if (selectedDate < Clock.System.todayIn(TimeZone.currentSystemDefault())) {
|
||||||
LocalDate.now()
|
Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||||
} else {
|
} else {
|
||||||
selectedDate
|
selectedDate
|
||||||
}
|
}
|
||||||
@@ -54,7 +60,7 @@ fun DatePicker(
|
|||||||
|
|
||||||
// Format the date for display
|
// Format the date for display
|
||||||
val formattedDate = remember(validatedDate) {
|
val formattedDate = remember(validatedDate) {
|
||||||
validatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
validatedDate.toJavaLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||||
}
|
}
|
||||||
val interactionSource = remember {
|
val interactionSource = remember {
|
||||||
object : MutableInteractionSource {
|
object : MutableInteractionSource {
|
||||||
@@ -93,9 +99,8 @@ fun DatePicker(
|
|||||||
if (showDatePicker) {
|
if (showDatePicker) {
|
||||||
val datePickerState = rememberDatePickerState(
|
val datePickerState = rememberDatePickerState(
|
||||||
initialSelectedDateMillis = selectedDate
|
initialSelectedDateMillis = selectedDate
|
||||||
.atStartOfDay(ZoneId.systemDefault())
|
.atStartOfDayIn(TimeZone.currentSystemDefault())
|
||||||
.toInstant()
|
.toEpochMilliseconds(),
|
||||||
.toEpochMilli(),
|
|
||||||
selectableDates = FutureSelectableDates(),
|
selectableDates = FutureSelectableDates(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -109,11 +114,11 @@ fun DatePicker(
|
|||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
datePickerState.selectedDateMillis?.let { millis ->
|
datePickerState.selectedDateMillis?.let { millis ->
|
||||||
val newDate = Instant.ofEpochMilli(millis)
|
|
||||||
.atZone(ZoneId.systemDefault())
|
val newDate = Instant.fromEpochMilliseconds(millis)
|
||||||
.toLocalDate()
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
// Only allow present and future dates
|
// Only allow present and future dates
|
||||||
if (!newDate.isBefore(LocalDate.now())) {
|
if (newDate >= Clock.System.todayIn(TimeZone.currentSystemDefault())) {
|
||||||
onDateSelect(newDate)
|
onDateSelect(newDate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,8 +146,8 @@ fun DatePicker(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
class FutureSelectableDates : SelectableDates {
|
class FutureSelectableDates : SelectableDates {
|
||||||
private val now = LocalDate.now()
|
private val now = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||||
private val dayStart = now.atTime(0, 0, 0, 0).toEpochSecond(ZoneOffset.UTC) * 1000
|
private val dayStart = now.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds()
|
||||||
|
|
||||||
@ExperimentalMaterial3Api
|
@ExperimentalMaterial3Api
|
||||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||||
@@ -159,8 +164,8 @@ class FutureSelectableDates : SelectableDates {
|
|||||||
@PreviewDevices
|
@PreviewDevices
|
||||||
@Composable
|
@Composable
|
||||||
private fun DatePickerPreview() {
|
private fun DatePickerPreview() {
|
||||||
val today = LocalDate.now()
|
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||||
val futureDate = today.plusDays(7) // A week from today
|
val futureDate = today.plus(7, DateTimeUnit.DAY)
|
||||||
|
|
||||||
FlightsTheme {
|
FlightsTheme {
|
||||||
DatePicker(
|
DatePicker(
|
||||||
|
@@ -14,6 +14,9 @@ import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
|||||||
import dev.adriankuta.flights.ui.home.HomeUiState
|
import dev.adriankuta.flights.ui.home.HomeUiState
|
||||||
import dev.adriankuta.flights.ui.home.PassengersState
|
import dev.adriankuta.flights.ui.home.PassengersState
|
||||||
import dev.adriankuta.flights.ui.sharedui.Counter
|
import dev.adriankuta.flights.ui.sharedui.Counter
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.todayIn
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PassengersOptions(
|
internal fun PassengersOptions(
|
||||||
@@ -71,7 +74,7 @@ private fun PassengersOptionsPreview() {
|
|||||||
destinationAirports = emptyList(),
|
destinationAirports = emptyList(),
|
||||||
selectedOriginAirport = null,
|
selectedOriginAirport = null,
|
||||||
selectedDestinationAirport = null,
|
selectedDestinationAirport = null,
|
||||||
selectedDate = java.time.LocalDate.now(),
|
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||||
passengers = samplePassengersState,
|
passengers = samplePassengersState,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -19,7 +19,12 @@ import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
|||||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||||
import dev.adriankuta.flights.ui.home.HomeUiState
|
import dev.adriankuta.flights.ui.home.HomeUiState
|
||||||
import dev.adriankuta.flights.ui.home.PassengersState
|
import dev.adriankuta.flights.ui.home.PassengersState
|
||||||
import java.time.LocalDate
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.todayIn
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SearchForm(
|
internal fun SearchForm(
|
||||||
@@ -155,7 +160,8 @@ private fun SearchFormPreview() {
|
|||||||
destinationAirports = sampleAirports.drop(1),
|
destinationAirports = sampleAirports.drop(1),
|
||||||
selectedOriginAirport = sampleAirports.first(),
|
selectedOriginAirport = sampleAirports.first(),
|
||||||
selectedDestinationAirport = sampleAirports.last(),
|
selectedDestinationAirport = sampleAirports.last(),
|
||||||
selectedDate = LocalDate.now().plusDays(7),
|
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||||
|
.plus(7, DateTimeUnit.DAY),
|
||||||
passengers = samplePassengersState,
|
passengers = samplePassengersState,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -0,0 +1,170 @@
|
|||||||
|
package dev.adriankuta.flights.ui.home.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
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.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.adriankuta.flights.domain.types.Flight
|
||||||
|
import dev.adriankuta.flights.domain.types.Trip
|
||||||
|
import dev.adriankuta.flights.domain.types.TripDate
|
||||||
|
import dev.adriankuta.flights.domain.types.TripFlight
|
||||||
|
import dev.adriankuta.flights.ui.home.SearchResultUiState
|
||||||
|
import dev.adriankuta.flights.ui.home.sample.FlightPreviewParameterProvider
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun SearchResults(
|
||||||
|
uiState: SearchResultUiState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
stickyHeader {
|
||||||
|
Text(
|
||||||
|
text = "Search Results",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (uiState) {
|
||||||
|
is SearchResultUiState.Error -> item { Text("Error") }
|
||||||
|
SearchResultUiState.Idle -> Unit
|
||||||
|
SearchResultUiState.Loading -> item { Text("Loading") }
|
||||||
|
is SearchResultUiState.Success -> items(uiState.flight.trips) { trip ->
|
||||||
|
TripCard(trip = trip, currency = uiState.flight.currency)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TripCard(
|
||||||
|
trip: Trip,
|
||||||
|
currency: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${trip.origin} → ${trip.destination}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
trip.dates.forEach { tripDate ->
|
||||||
|
TripDateItem(tripDate = tripDate, currency = currency)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TripDateItem(
|
||||||
|
tripDate: TripDate,
|
||||||
|
currency: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = "Date: ${tripDate.dateOut}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
tripDate.flights.forEach { flight ->
|
||||||
|
FlightItem(flight = flight, currency = currency)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FlightItem(
|
||||||
|
flight: TripFlight,
|
||||||
|
currency: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Flight: ${flight.flightNumber}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Duration: ${flight.duration}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Operated by: ${flight.operatedBy}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Display price information
|
||||||
|
val totalPrice = flight.regularFare.fares.sumOf { it.amount * it.count }
|
||||||
|
Text(
|
||||||
|
text = "Price: $totalPrice $currency",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Seats available: ${flight.faresLeft}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultsPreview(@PreviewParameter(FlightPreviewParameterProvider::class) flight: Flight) {
|
||||||
|
SearchResults(uiState = SearchResultUiState.Success(flight))
|
||||||
|
}
|
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
package dev.adriankuta.flights.ui.home.navigation
|
package dev.adriankuta.flights.ui.home.navigation
|
||||||
|
|
||||||
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.NavOptions
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import dev.adriankuta.flights.ui.home.HomeScreen
|
import dev.adriankuta.flights.ui.home.HomeScreen
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -10,6 +12,12 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object HomeRoute
|
data object HomeRoute
|
||||||
|
|
||||||
|
fun NavController.navigateToHome(
|
||||||
|
navOptions: NavOptions,
|
||||||
|
) {
|
||||||
|
navigate(route = HomeRoute, navOptions = navOptions)
|
||||||
|
}
|
||||||
|
|
||||||
fun NavGraphBuilder.homeScreen() {
|
fun NavGraphBuilder.homeScreen() {
|
||||||
composable<HomeRoute> {
|
composable<HomeRoute> {
|
||||||
HomeScreen()
|
HomeScreen()
|
||||||
|
@@ -0,0 +1,142 @@
|
|||||||
|
package dev.adriankuta.flights.ui.home.sample
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import dev.adriankuta.flights.domain.types.Flight
|
||||||
|
import dev.adriankuta.flights.domain.types.RegularFare
|
||||||
|
import dev.adriankuta.flights.domain.types.Segment
|
||||||
|
import dev.adriankuta.flights.domain.types.Trip
|
||||||
|
import dev.adriankuta.flights.domain.types.TripDate
|
||||||
|
import dev.adriankuta.flights.domain.types.TripFare
|
||||||
|
import dev.adriankuta.flights.domain.types.TripFlight
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
internal class FlightPreviewParameterProvider : PreviewParameterProvider<Flight> {
|
||||||
|
override val values: Sequence<Flight> = sequenceOf(
|
||||||
|
Flight(
|
||||||
|
currency = "EUR",
|
||||||
|
currPrecision = 2,
|
||||||
|
trips = listOf(
|
||||||
|
Trip(
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
dates = listOf(
|
||||||
|
TripDate(
|
||||||
|
dateOut = LocalDate(2023, 6, 15),
|
||||||
|
flights = listOf(
|
||||||
|
TripFlight(
|
||||||
|
faresLeft = 5,
|
||||||
|
regularFare = RegularFare(
|
||||||
|
fares = listOf(
|
||||||
|
TripFare(
|
||||||
|
type = "ADULT",
|
||||||
|
amount = 99.99,
|
||||||
|
count = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-15T10:00:00",
|
||||||
|
"2023-06-15T12:30:00",
|
||||||
|
),
|
||||||
|
duration = "2h 30m",
|
||||||
|
segments = listOf(
|
||||||
|
Segment(
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
flightNumber = "FR1234",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-15T10:00:00",
|
||||||
|
"2023-06-15T12:30:00",
|
||||||
|
),
|
||||||
|
duration = "2h 30m",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
operatedBy = "Ryanair",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TripDate(
|
||||||
|
dateOut = LocalDate(2023, 6, 16),
|
||||||
|
flights = listOf(
|
||||||
|
TripFlight(
|
||||||
|
faresLeft = 3,
|
||||||
|
regularFare = RegularFare(
|
||||||
|
fares = listOf(
|
||||||
|
TripFare(
|
||||||
|
type = "ADULT",
|
||||||
|
amount = 129.99,
|
||||||
|
count = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flightNumber = "FR5678",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-16T14:00:00",
|
||||||
|
"2023-06-16T16:15:00",
|
||||||
|
),
|
||||||
|
duration = "2h 15m",
|
||||||
|
segments = listOf(
|
||||||
|
Segment(
|
||||||
|
origin = "WAW",
|
||||||
|
destination = "LDN",
|
||||||
|
flightNumber = "FR5678",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-16T14:00:00",
|
||||||
|
"2023-06-16T16:15:00",
|
||||||
|
),
|
||||||
|
duration = "2h 15m",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
operatedBy = "Ryanair",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Trip(
|
||||||
|
origin = "LDN",
|
||||||
|
destination = "WAW",
|
||||||
|
dates = listOf(
|
||||||
|
TripDate(
|
||||||
|
dateOut = LocalDate(2023, 6, 22),
|
||||||
|
flights = listOf(
|
||||||
|
TripFlight(
|
||||||
|
faresLeft = 10,
|
||||||
|
regularFare = RegularFare(
|
||||||
|
fares = listOf(
|
||||||
|
TripFare(
|
||||||
|
type = "ADULT",
|
||||||
|
amount = 89.99,
|
||||||
|
count = 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flightNumber = "FR9876",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-22T08:30:00",
|
||||||
|
"2023-06-22T10:45:00",
|
||||||
|
),
|
||||||
|
duration = "2h 15m",
|
||||||
|
segments = listOf(
|
||||||
|
Segment(
|
||||||
|
origin = "LDN",
|
||||||
|
destination = "WAW",
|
||||||
|
flightNumber = "FR9876",
|
||||||
|
dateTimes = listOf(
|
||||||
|
"2023-06-22T08:30:00",
|
||||||
|
"2023-06-22T10:45:00",
|
||||||
|
),
|
||||||
|
duration = "2h 15m",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
operatedBy = "Ryanair",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
@@ -0,0 +1,59 @@
|
|||||||
|
package dev.adriankuta.flights.ui.home.sample
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
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.ui.home.HomeUiState
|
||||||
|
import dev.adriankuta.flights.ui.home.PassengersState
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.todayIn
|
||||||
|
|
||||||
|
internal class HomeUiStatePreviewParameterProvider : PreviewParameterProvider<HomeUiState> {
|
||||||
|
|
||||||
|
private val mockAirports = listOf(
|
||||||
|
AirportInfo(
|
||||||
|
code = "WAW",
|
||||||
|
name = "Warsaw Chopin Airport",
|
||||||
|
seoName = "warsaw",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
city = City("WAW", "Warsaw"),
|
||||||
|
macCity = MacCity("WARSAW"),
|
||||||
|
region = Region("WARSAW_PL", "Warsaw"),
|
||||||
|
country = Country("PL", "Poland", "PLN"),
|
||||||
|
coordinates = Coordinates(52.1657, 20.9671),
|
||||||
|
),
|
||||||
|
AirportInfo(
|
||||||
|
code = "KRK",
|
||||||
|
name = "Krakow Airport",
|
||||||
|
seoName = "krakow",
|
||||||
|
isBase = true,
|
||||||
|
timeZone = "Europe/Warsaw",
|
||||||
|
city = City("KRK", "Krakow"),
|
||||||
|
macCity = MacCity("KRAKOW"),
|
||||||
|
region = Region("KRAKOW_PL", "Krakow"),
|
||||||
|
country = Country("PL", "Poland", "PLN"),
|
||||||
|
coordinates = Coordinates(50.0777, 19.7848),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
override val values: Sequence<HomeUiState> = sequenceOf(
|
||||||
|
HomeUiState.Success(
|
||||||
|
originAirports = mockAirports,
|
||||||
|
destinationAirports = mockAirports,
|
||||||
|
selectedOriginAirport = mockAirports.first(),
|
||||||
|
selectedDestinationAirport = mockAirports.last(),
|
||||||
|
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||||
|
passengers = PassengersState(
|
||||||
|
adultCount = 1,
|
||||||
|
teenCount = 0,
|
||||||
|
childCount = 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
4
ui/home/src/main/res/values/strings.xml
Normal file
4
ui/home/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="home_screen_title">Search Flight</string>
|
||||||
|
</resources>
|
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)
|
||||||
|
}
|
33
ui/stations/config/detekt/detekt.yml
Normal file
33
ui/stations/config/detekt/detekt.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Exceptions for compose. See https://detekt.dev/docs/introduction/compose
|
||||||
|
naming:
|
||||||
|
FunctionNaming:
|
||||||
|
functionPattern: '[a-zA-Z][a-zA-Z0-9]*'
|
||||||
|
|
||||||
|
TopLevelPropertyNaming:
|
||||||
|
constantPattern: '[A-Z][A-Za-z0-9]*'
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
LongParameterList:
|
||||||
|
ignoreAnnotated: ['Composable']
|
||||||
|
TooManyFunctions:
|
||||||
|
ignoreAnnotatedFunctions: ['Preview']
|
||||||
|
|
||||||
|
style:
|
||||||
|
MagicNumber:
|
||||||
|
ignorePropertyDeclaration: true
|
||||||
|
ignoreCompanionObjectPropertyDeclaration: true
|
||||||
|
ignoreAnnotated: ['Composable']
|
||||||
|
|
||||||
|
UnusedPrivateMember:
|
||||||
|
ignoreAnnotated: ['Composable']
|
||||||
|
|
||||||
|
# Deviations from defaults
|
||||||
|
formatting:
|
||||||
|
TrailingCommaOnCallSite:
|
||||||
|
active: true
|
||||||
|
autoCorrect: true
|
||||||
|
useTrailingCommaOnCallSite: true
|
||||||
|
TrailingCommaOnDeclarationSite:
|
||||||
|
active: true
|
||||||
|
autoCorrect: true
|
||||||
|
useTrailingCommaOnDeclarationSite: true
|
18
ui/stations/lint-baseline.xml
Normal file
18
ui/stations/lint-baseline.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<issues format="6" by="lint 8.10.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.10.1)" variant="all" version="8.10.1">
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="Aligned16KB"
|
||||||
|
message="The native library `arm64-v8a/libmockkjvmtiagent.so` (from `io.mockk:mockk-agent-android:1.14.2`) is not 16 KB aligned">
|
||||||
|
<location
|
||||||
|
file="$GRADLE_USER_HOME/caches/8.11.1/transforms/60089ed8c197d152569cb75de3649edc/transformed/mockk-agent-android-1.14.2/jni/arm64-v8a/libmockkjvmtiagent.so"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="Aligned16KB"
|
||||||
|
message="The native library `arm64-v8a/libmockkjvmtiagent.so` (from `io.mockk:mockk-agent-android:1.14.2`) is not 16 KB aligned">
|
||||||
|
<location
|
||||||
|
file="$GRADLE_USER_HOME/caches/8.11.1/transforms/60089ed8c197d152569cb75de3649edc/transformed/mockk-agent-android-1.14.2/jni/arm64-v8a/libmockkjvmtiagent.so"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
|
</issues>
|
@@ -0,0 +1,125 @@
|
|||||||
|
package dev.adriankuta.flights.ui.home
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
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.AirportInfoCompact
|
||||||
|
import dev.adriankuta.flights.ui.home.components.AirportInfoItem
|
||||||
|
import dev.adriankuta.flights.ui.home.components.CountryItem
|
||||||
|
import dev.adriankuta.flights.ui.stations.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun StationsScreen(
|
||||||
|
stationScreenViewModel: StationsScreenViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val originAirports by stationScreenViewModel.originAirports.collectAsStateWithLifecycle()
|
||||||
|
val searchQuery by stationScreenViewModel.searchQuery.collectAsStateWithLifecycle()
|
||||||
|
val selectedOriginAirport by stationScreenViewModel.selectedOriginAirport.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
BackHandler(selectedOriginAirport != null) {
|
||||||
|
stationScreenViewModel.clearSelectedAirport()
|
||||||
|
}
|
||||||
|
|
||||||
|
StationsScreen(
|
||||||
|
originAirports = originAirports,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
selectedOriginAirport = selectedOriginAirport,
|
||||||
|
onSearchQueryChang = stationScreenViewModel::onSearchQueryChanged,
|
||||||
|
onAirportSelect = stationScreenViewModel::onAirportSelected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun StationsScreen(
|
||||||
|
originAirports: AirportsUiState,
|
||||||
|
searchQuery: String,
|
||||||
|
selectedOriginAirport: AirportInfo?,
|
||||||
|
onSearchQueryChang: (String) -> Unit,
|
||||||
|
onAirportSelect: (AirportInfo) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(selectedOriginAirport != null) {
|
||||||
|
var airportInfo by remember { mutableStateOf(selectedOriginAirport!!) }
|
||||||
|
LaunchedEffect(selectedOriginAirport) {
|
||||||
|
selectedOriginAirport?.let { airportInfo = it }
|
||||||
|
}
|
||||||
|
AirportInfoCompact(
|
||||||
|
data = airportInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = onSearchQueryChang,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
placeholder = { Text(text = stringResource(R.string.search_airports)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
when (originAirports) {
|
||||||
|
is AirportsUiState.Error -> item { Text("Error") }
|
||||||
|
is AirportsUiState.Loading -> item { Text("Loading") }
|
||||||
|
is AirportsUiState.Success -> groupedAirports(
|
||||||
|
data = originAirports.groupedAirports,
|
||||||
|
onAirportSelected = onAirportSelect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LazyListScope.groupedAirports(
|
||||||
|
data: Map<Country, List<AirportInfo>>,
|
||||||
|
onAirportSelected: (AirportInfo) -> Unit,
|
||||||
|
) {
|
||||||
|
data.keys.forEach { country ->
|
||||||
|
stickyHeader {
|
||||||
|
key(country.code) {
|
||||||
|
CountryItem(data = country, modifier = Modifier.animateItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(data[country]!!) { airport ->
|
||||||
|
key(airport.code) {
|
||||||
|
AirportInfoItem(
|
||||||
|
data = airport,
|
||||||
|
modifier = Modifier.animateItem(),
|
||||||
|
onClick = onAirportSelected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,112 @@
|
|||||||
|
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.GetConnectionsForAirportUseCase
|
||||||
|
import dev.adriankuta.flights.domain.stations.ObserveAirportsGroupedByCountry
|
||||||
|
import dev.adriankuta.flights.domain.types.Airport
|
||||||
|
import dev.adriankuta.flights.domain.types.AirportInfo
|
||||||
|
import dev.adriankuta.flights.domain.types.Country
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
internal class StationsScreenViewModel @Inject constructor(
|
||||||
|
observeAirportsGroupedByCountry: ObserveAirportsGroupedByCountry,
|
||||||
|
getConnectionsForAirportUseCase: GetConnectionsForAirportUseCase,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _searchQuery = MutableStateFlow("")
|
||||||
|
val searchQuery: StateFlow<String> = _searchQuery
|
||||||
|
|
||||||
|
private val _selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
||||||
|
val selectedOriginAirport: StateFlow<AirportInfo?> = _selectedOriginAirport
|
||||||
|
private val _availableDestinationsCodes = selectedOriginAirport.transformLatest { airportInfo ->
|
||||||
|
val result = airportInfo?.let { nonNullAirportInfo ->
|
||||||
|
getConnectionsForAirportUseCase(nonNullAirportInfo.toDepartureAirPort())
|
||||||
|
.filter { it is Airport.Arrival }
|
||||||
|
.map { it.code }
|
||||||
|
}.orEmpty()
|
||||||
|
emit(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchQueryChanged(query: String) {
|
||||||
|
_searchQuery.value = query
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAirportSelected(airport: AirportInfo) {
|
||||||
|
_selectedOriginAirport.value = airport
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelectedAirport() {
|
||||||
|
_selectedOriginAirport.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val originAirports = airportsUiState(
|
||||||
|
useCase = observeAirportsGroupedByCountry,
|
||||||
|
searchQuery = searchQuery,
|
||||||
|
filterDestinations = _availableDestinationsCodes,
|
||||||
|
).stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = AirportsUiState.Loading,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun airportsUiState(
|
||||||
|
useCase: ObserveAirportsGroupedByCountry,
|
||||||
|
searchQuery: StateFlow<String>,
|
||||||
|
filterDestinations: Flow<List<String>>,
|
||||||
|
): Flow<AirportsUiState> {
|
||||||
|
return combine(
|
||||||
|
useCase().asResult(),
|
||||||
|
searchQuery,
|
||||||
|
filterDestinations,
|
||||||
|
) { result, query, destinationCodes ->
|
||||||
|
when (result) {
|
||||||
|
is Result.Error -> AirportsUiState.Error(result.exception)
|
||||||
|
is Result.Loading -> AirportsUiState.Loading
|
||||||
|
is Result.Success -> {
|
||||||
|
val airports = result.data.orEmpty()
|
||||||
|
if (query.isBlank() && destinationCodes.isEmpty()) {
|
||||||
|
AirportsUiState.Success(airports)
|
||||||
|
} else {
|
||||||
|
val filteredAirports = airports.mapValues { (_, airportList) ->
|
||||||
|
airportList.asSequence()
|
||||||
|
.filter { destinationCodes.isEmpty() || destinationCodes.contains(it.code) }
|
||||||
|
.filter { airport ->
|
||||||
|
airport.name.contains(query, ignoreCase = true) ||
|
||||||
|
airport.code.contains(query, ignoreCase = true) ||
|
||||||
|
airport.city.name.contains(query, ignoreCase = true) ||
|
||||||
|
airport.country.name.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}.filterValues { it.isNotEmpty() }
|
||||||
|
AirportsUiState.Success(filteredAirports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AirportInfo.toDepartureAirPort(): Airport.Departure =
|
||||||
|
Airport.Departure(
|
||||||
|
code = code,
|
||||||
|
name = name,
|
||||||
|
macCity = macCity,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed interface AirportsUiState {
|
||||||
|
data object Loading : AirportsUiState
|
||||||
|
data class Success(val groupedAirports: Map<Country, List<AirportInfo>>) : AirportsUiState
|
||||||
|
data class Error(val exception: Throwable) : AirportsUiState
|
||||||
|
}
|
@@ -0,0 +1,60 @@
|
|||||||
|
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 AirportInfoCompact(
|
||||||
|
data: AirportInfo,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = data.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${data.code} • ${data.city.name}",
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,74 @@
|
|||||||
|
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,
|
||||||
|
onClick: (AirportInfo) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
onClick = { onClick(data) },
|
||||||
|
) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
5
ui/stations/src/main/res/values/strings.xml
Normal file
5
ui/stations/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="stations_screen_title">Stations</string>
|
||||||
|
<string name="search_airports">Search airports</string>
|
||||||
|
</resources>
|
Reference in New Issue
Block a user