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 {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.flights.android.application.compose)
|
||||
alias(libs.plugins.flights.android.application.hilt)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -60,6 +60,7 @@ dependencies {
|
||||
|
||||
implementation(projects.ui.designsystem)
|
||||
implementation(projects.ui.home)
|
||||
implementation(projects.ui.stations)
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
@@ -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
|
||||
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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 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
|
||||
|
||||
@Composable
|
||||
fun FlightsApp(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
Surface(
|
||||
tonalElevation = Elevation.Surface,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = { InAppUpdates() },
|
||||
bottomBar = {
|
||||
FlightsBottomBar(
|
||||
navController = navController,
|
||||
)
|
||||
},
|
||||
) { 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 {
|
||||
"implementation"(libs.findLibrary("androidx.core.ktx").get())
|
||||
"implementation"(libs.findLibrary("kotlinx.coroutines.android").get())
|
||||
"implementation"(libs.findLibrary("kotlinx.datetime").get())
|
||||
"implementation"(libs.findLibrary("timber").get())
|
||||
|
||||
"coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())
|
@@ -9,5 +9,4 @@ android {
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.types)
|
||||
implementation(libs.timber)
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
package dev.adriankuta.flights.domain.search.entities
|
||||
|
||||
import dev.adriankuta.flights.domain.types.Airport
|
||||
import java.time.LocalDate
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
data class SearchOptions(
|
||||
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 {
|
||||
implementation(projects.core.util)
|
||||
implementation(libs.timber)
|
||||
}
|
@@ -1,6 +1,8 @@
|
||||
package dev.adriankuta.flights.domain.types
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
data class TripDate(
|
||||
val dateOut: String,
|
||||
val dateOut: LocalDate?,
|
||||
val flights: List<TripFlight>,
|
||||
)
|
||||
|
@@ -8,7 +8,9 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.util)
|
||||
implementation(projects.domain.search)
|
||||
implementation(projects.domain.stations)
|
||||
implementation(projects.model.data.api)
|
||||
implementation(projects.model.datasource.airports)
|
||||
|
||||
@@ -16,4 +18,5 @@ dependencies {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
|
||||
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.TripFlight
|
||||
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.Segment as ApiSegment
|
||||
import dev.adriankuta.model.data.api.entities.Trip as ApiTrip
|
||||
@@ -33,7 +34,7 @@ internal fun ApiTrip.toDomain(): Trip {
|
||||
|
||||
internal fun ApiTripDate.toDomain(): TripDate {
|
||||
return TripDate(
|
||||
dateOut = dateOut ?: "",
|
||||
dateOut = dateOut?.let { LocalDateTime.parse(it).date },
|
||||
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.model.repository.mappers.toDomain
|
||||
import dev.adriankuta.model.data.api.FlightService
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
|
||||
@@ -16,7 +15,7 @@ internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
|
||||
val result = flightService.getFlights(
|
||||
origin = searchOptions.origin.code,
|
||||
destination = searchOptions.destination.code,
|
||||
date = searchOptions.date.format(DateTimeFormatter.ISO_DATE),
|
||||
date = searchOptions.date.toString(),
|
||||
adult = searchOptions.adults,
|
||||
teen = searchOptions.teens,
|
||||
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(
|
||||
onCacheInvalidated: suspend (cacheKey: String) -> Unit,
|
||||
observeCache: () -> Flow<Cache<T>>,
|
||||
mapToDomain: (T?) -> R?,
|
||||
mapToDomain: suspend (T?) -> R?,
|
||||
): Flow<R?> {
|
||||
return observeCache().distinctUntilChanged().map {
|
||||
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(":core:util")
|
||||
include(":domain:search")
|
||||
include(":domain:stations")
|
||||
include(":domain:types")
|
||||
include(":model:data:api")
|
||||
include(":model:data:room")
|
||||
@@ -36,3 +37,4 @@ include(":model:repository")
|
||||
include(":ui:designsystem")
|
||||
include(":ui:home")
|
||||
include(":ui:sharedui")
|
||||
include(":ui:stations")
|
||||
|
@@ -15,5 +15,4 @@ dependencies {
|
||||
implementation(projects.domain.search)
|
||||
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
package dev.adriankuta.flights.ui.home
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -7,29 +8,39 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.tooling.preview.PreviewParameter
|
||||
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.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.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 dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||
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
|
||||
internal fun HomeScreen(
|
||||
viewModel: HomeScreenViewModel = hiltViewModel(),
|
||||
) {
|
||||
val homeUiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResultUiState.collectAsStateWithLifecycle()
|
||||
|
||||
HomeScreen(
|
||||
uiState = homeUiState,
|
||||
searchResultsUiState = searchResults,
|
||||
onOriginAirportSelect = viewModel::selectOriginAirport,
|
||||
onDestinationAirportSelect = viewModel::selectDestinationAirport,
|
||||
onDateSelect = viewModel::selectDate,
|
||||
@@ -43,6 +54,7 @@ internal fun HomeScreen(
|
||||
@Composable
|
||||
private fun HomeScreen(
|
||||
uiState: HomeUiState,
|
||||
searchResultsUiState: SearchResultUiState,
|
||||
onOriginAirportSelect: (AirportInfo) -> Unit,
|
||||
onDestinationAirportSelect: (AirportInfo) -> Unit,
|
||||
onDateSelect: (LocalDate) -> Unit,
|
||||
@@ -52,18 +64,25 @@ private fun HomeScreen(
|
||||
onSearch: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showSearchResults by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = showSearchResults) {
|
||||
showSearchResults = false
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(
|
||||
state = scrollState,
|
||||
),
|
||||
.padding(16.dp),
|
||||
) {
|
||||
when (uiState) {
|
||||
is HomeUiState.Error -> Text("Error")
|
||||
HomeUiState.Loading -> Text("Loading")
|
||||
is HomeUiState.Success -> SearchForm(
|
||||
is HomeUiState.Success -> if (showSearchResults) {
|
||||
SearchResults(
|
||||
uiState = searchResultsUiState,
|
||||
)
|
||||
} else {
|
||||
SearchForm(
|
||||
uiState = uiState,
|
||||
onOriginAirportSelect = onOriginAirportSelect,
|
||||
onDestinationAirportSelect = onDestinationAirportSelect,
|
||||
@@ -71,10 +90,15 @@ private fun HomeScreen(
|
||||
onAdultCountChange = onAdultCountChange,
|
||||
onTeenCountChange = onTeenCountChange,
|
||||
onChildCountChange = onChildCountChange,
|
||||
onSearch = onSearch,
|
||||
onSearch = {
|
||||
showSearchResults = true
|
||||
onSearch()
|
||||
},
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewDevices
|
||||
@@ -83,6 +107,7 @@ private fun HomeScreenLoadingPreview() {
|
||||
FlightsTheme {
|
||||
HomeScreen(
|
||||
uiState = HomeUiState.Loading,
|
||||
searchResultsUiState = SearchResultUiState.Idle,
|
||||
onOriginAirportSelect = {},
|
||||
onDestinationAirportSelect = {},
|
||||
onDateSelect = {},
|
||||
@@ -96,48 +121,83 @@ private fun HomeScreenLoadingPreview() {
|
||||
|
||||
@PreviewDevices
|
||||
@Composable
|
||||
private fun HomeScreenSuccessPreview() {
|
||||
private fun HomeScreenSuccessPreview(
|
||||
@PreviewParameter(HomeUiStatePreviewParameterProvider::class) homeUiState: HomeUiState,
|
||||
) {
|
||||
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(
|
||||
uiState = HomeUiState.Success(
|
||||
originAirports = mockAirports,
|
||||
destinationAirports = mockAirports,
|
||||
selectedOriginAirport = mockAirports.first(),
|
||||
selectedDestinationAirport = mockAirports.last(),
|
||||
selectedDate = LocalDate.now(),
|
||||
passengers = PassengersState(
|
||||
adultCount = 2,
|
||||
teenCount = 1,
|
||||
childCount = 1,
|
||||
),
|
||||
),
|
||||
uiState = homeUiState,
|
||||
searchResultsUiState = SearchResultUiState.Idle,
|
||||
onOriginAirportSelect = {},
|
||||
onDestinationAirportSelect = {},
|
||||
onDateSelect = {},
|
||||
onAdultCountChange = {},
|
||||
onTeenCountChange = {},
|
||||
onChildCountChange = {},
|
||||
onSearch = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 = {},
|
||||
onDestinationAirportSelect = {},
|
||||
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.types.Airport
|
||||
import dev.adriankuta.flights.domain.types.AirportInfo
|
||||
import dev.adriankuta.flights.domain.types.Flight
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -17,8 +18,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -29,10 +32,12 @@ class HomeScreenViewModel @Inject constructor(
|
||||
|
||||
private val selectedOriginAirport = 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 teenCount = MutableStateFlow(0)
|
||||
private val childCount = MutableStateFlow(0)
|
||||
private val searchResults = MutableStateFlow<SearchResultUiState>(SearchResultUiState.Idle)
|
||||
|
||||
internal val uiState = homeUiState(
|
||||
useCase = observeAirportsUseCase,
|
||||
@@ -51,6 +56,13 @@ class HomeScreenViewModel @Inject constructor(
|
||||
initialValue = HomeUiState.Loading,
|
||||
)
|
||||
|
||||
internal val searchResultUiState = searchResults
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = SearchResultUiState.Idle,
|
||||
)
|
||||
|
||||
fun selectOriginAirport(airport: AirportInfo) {
|
||||
selectedOriginAirport.value = airport
|
||||
// If the selected destination is the same as the new origin, clear the destination
|
||||
@@ -91,27 +103,39 @@ class HomeScreenViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun search() {
|
||||
searchResults.value = SearchResultUiState.Loading
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val results = getFlightsSearchContentUseCase(
|
||||
searchOptions = SearchOptions(
|
||||
searchOptions = prepareSearchOptions(),
|
||||
)
|
||||
searchResults.value = SearchResultUiState.Success(results)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
searchResults.value = SearchResultUiState.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareSearchOptions(): SearchOptions {
|
||||
val selectedOrigin = requireNotNull(selectedOriginAirport.value)
|
||||
val selectedDestination = requireNotNull(selectedDestinationAirport.value)
|
||||
|
||||
return SearchOptions(
|
||||
origin = Airport.Departure(
|
||||
code = selectedOriginAirport.value?.code ?: return@launch,
|
||||
name = selectedOriginAirport.value?.name ?: return@launch,
|
||||
macCity = selectedOriginAirport.value?.macCity,
|
||||
code = selectedOrigin.code,
|
||||
name = selectedOrigin.name,
|
||||
macCity = selectedOrigin.macCity,
|
||||
),
|
||||
destination = Airport.Arrival(
|
||||
code = selectedDestinationAirport.value?.code ?: return@launch,
|
||||
name = selectedDestinationAirport.value?.name ?: return@launch,
|
||||
macCity = selectedDestinationAirport.value?.macCity,
|
||||
code = selectedDestination.code,
|
||||
name = selectedDestination.name,
|
||||
macCity = selectedDestination.macCity,
|
||||
),
|
||||
date = selectedDate.value,
|
||||
adults = adultCount.value,
|
||||
teens = teenCount.value,
|
||||
children = childCount.value,
|
||||
),
|
||||
)
|
||||
Timber.d("Result $results")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,13 +196,20 @@ internal sealed interface HomeUiState {
|
||||
val destinationAirports: List<AirportInfo>,
|
||||
val selectedOriginAirport: AirportInfo? = null,
|
||||
val selectedDestinationAirport: AirportInfo? = null,
|
||||
val selectedDate: LocalDate = LocalDate.now(),
|
||||
val selectedDate: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||
val passengers: PassengersState,
|
||||
) : 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(
|
||||
val adultCount: 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 kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.Instant
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -45,8 +51,8 @@ fun DatePicker(
|
||||
|
||||
// Ensure the selected date is not in the past
|
||||
val validatedDate = remember(selectedDate) {
|
||||
if (selectedDate.isBefore(LocalDate.now())) {
|
||||
LocalDate.now()
|
||||
if (selectedDate < Clock.System.todayIn(TimeZone.currentSystemDefault())) {
|
||||
Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
} else {
|
||||
selectedDate
|
||||
}
|
||||
@@ -54,7 +60,7 @@ fun DatePicker(
|
||||
|
||||
// Format the date for display
|
||||
val formattedDate = remember(validatedDate) {
|
||||
validatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
validatedDate.toJavaLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
}
|
||||
val interactionSource = remember {
|
||||
object : MutableInteractionSource {
|
||||
@@ -93,9 +99,8 @@ fun DatePicker(
|
||||
if (showDatePicker) {
|
||||
val datePickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = selectedDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli(),
|
||||
.atStartOfDayIn(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds(),
|
||||
selectableDates = FutureSelectableDates(),
|
||||
)
|
||||
|
||||
@@ -109,11 +114,11 @@ fun DatePicker(
|
||||
TextButton(
|
||||
onClick = {
|
||||
datePickerState.selectedDateMillis?.let { millis ->
|
||||
val newDate = Instant.ofEpochMilli(millis)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
|
||||
val newDate = Instant.fromEpochMilliseconds(millis)
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
// Only allow present and future dates
|
||||
if (!newDate.isBefore(LocalDate.now())) {
|
||||
if (newDate >= Clock.System.todayIn(TimeZone.currentSystemDefault())) {
|
||||
onDateSelect(newDate)
|
||||
}
|
||||
}
|
||||
@@ -141,8 +146,8 @@ fun DatePicker(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class FutureSelectableDates : SelectableDates {
|
||||
private val now = LocalDate.now()
|
||||
private val dayStart = now.atTime(0, 0, 0, 0).toEpochSecond(ZoneOffset.UTC) * 1000
|
||||
private val now = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
private val dayStart = now.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds()
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
@@ -159,8 +164,8 @@ class FutureSelectableDates : SelectableDates {
|
||||
@PreviewDevices
|
||||
@Composable
|
||||
private fun DatePickerPreview() {
|
||||
val today = LocalDate.now()
|
||||
val futureDate = today.plusDays(7) // A week from today
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
val futureDate = today.plus(7, DateTimeUnit.DAY)
|
||||
|
||||
FlightsTheme {
|
||||
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.PassengersState
|
||||
import dev.adriankuta.flights.ui.sharedui.Counter
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
@Composable
|
||||
internal fun PassengersOptions(
|
||||
@@ -71,7 +74,7 @@ private fun PassengersOptionsPreview() {
|
||||
destinationAirports = emptyList(),
|
||||
selectedOriginAirport = null,
|
||||
selectedDestinationAirport = null,
|
||||
selectedDate = java.time.LocalDate.now(),
|
||||
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||
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.home.HomeUiState
|
||||
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
|
||||
internal fun SearchForm(
|
||||
@@ -155,7 +160,8 @@ private fun SearchFormPreview() {
|
||||
destinationAirports = sampleAirports.drop(1),
|
||||
selectedOriginAirport = sampleAirports.first(),
|
||||
selectedDestinationAirport = sampleAirports.last(),
|
||||
selectedDate = LocalDate.now().plusDays(7),
|
||||
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
.plus(7, DateTimeUnit.DAY),
|
||||
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
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import dev.adriankuta.flights.ui.home.HomeScreen
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -10,6 +12,12 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data object HomeRoute
|
||||
|
||||
fun NavController.navigateToHome(
|
||||
navOptions: NavOptions,
|
||||
) {
|
||||
navigate(route = HomeRoute, navOptions = navOptions)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.homeScreen() {
|
||||
composable<HomeRoute> {
|
||||
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