Compare commits

...

6 Commits

Author SHA1 Message Date
Adrian Kuta
8ce553240c Test: Add unit tests for repository use cases
This commit introduces unit tests for the following use cases in the repository layer:
- `ObserveAirportsUseCaseImpl`
- `GetFlightsSearchContentUseCaseImpl`
- `GetConnectionsForAirportUseCaseImpl`

Key changes:
- Added `ObserveAirportsUseCaseImplTest.kt` with tests for observing airport data, including scenarios with valid data, empty data, null data, and cache refresh.
- Added `GetFlightsSearchContentUseCaseImplTest.kt` with tests for searching flights, covering valid responses and responses with null values.
- Added `GetConnectionsForAirportUseCaseImplTest.kt` with tests for fetching airport connections, including scenarios with valid responses, empty responses, responses with null airport codes/names, and multiple route responses.
- Added `kotlinx-coroutines-test` dependency to `model/repository/build.gradle.kts` for testing coroutines.
2025-06-16 00:55:12 +02:00
Adrian Kuta
e8ac7c5596 feat: Implement airport connection fetching and filtering
This commit introduces the functionality to fetch available flight connections for a selected origin airport and filter the destination airport list accordingly.

Key changes:
- Added `GetConnectionsForAirportUseCase` interface in `domain/stations` to define the contract for fetching airport connections.
- Implemented `GetConnectionsForAirportUseCaseImpl` in `model/repository` to fetch routes from the `RoutesService` and map them to domain `Airport` types.
- Created `AirportDomainMapper.kt` in `model/repository/mappers` to map `RouteResponse` and its nested types to domain `Airport` and `MacCity` types.
- Added a Hilt module `GetConnectionsForAirportUseCaseModule` to provide the implementation for `GetConnectionsForAirportUseCase`.
- Renamed `OriginAirportsUiState` to `AirportsUiState` in `StationsScreen.kt` and `StationsScreenViewModel.kt` for better generality.
- Updated `StationsScreenViewModel`:
    - Injected `GetConnectionsForAirportUseCase`.
    - Added `_availableDestinationsCodes` StateFlow to hold the codes of airports reachable from the selected origin.
    - Modified `airportsUiState` (previously `originAirportsUiState`) to take `filterDestinations` as a parameter and filter airports based on both search query and available destination codes.
    - Added `AirportInfo.toDepartureAirPort()` extension function.
- Updated `StationsScreen.kt` to use the renamed `AirportsUiState`.
2025-06-16 00:29:57 +02:00
Adrian Kuta
504f798bd3 feat: Implement airport search and selection in StationsScreen
This commit introduces search functionality and airport selection to the StationsScreen. Users can now search for airports and select an origin airport, which will be displayed in a compact view.

Key changes:
- Added a search bar (`OutlinedTextField`) to `StationsScreen` to filter the list of airports.
- Implemented `searchQuery` StateFlow in `StationsScreenViewModel` to hold the current search term.
- Updated `originAirportsUiState` to filter airports based on the `searchQuery`.
- Added `selectedOriginAirport` StateFlow in `StationsScreenViewModel` to manage the selected airport.
- Introduced `AirportInfoCompact` composable to display the selected origin airport.
- Implemented `onAirportSelected` and `clearSelectedAirport` functions in `StationsScreenViewModel` to handle airport selection and clearing.
- Added `BackHandler` to clear the selected airport when the back button is pressed.
- Made `AirportInfoItem` clickable to trigger airport selection.
- Added string resource for "Search airports" placeholder.
- Added Detekt configuration files for `domain/stations` and `ui/stations` modules.
- Added Lint baseline files for `domain/stations` and `ui/stations` modules.
- Used `key` and `animateItem` for better performance and animations in `LazyColumn`.
2025-06-15 23:41:00 +02:00
Adrian Kuta
3e9768919d feat: Implement stations screen with grouped airports
This commit introduces a new "Stations" screen that displays airports grouped by country.

Key changes:
- Added a new Gradle module: `ui:stations`.
- Created `StationsScreen.kt` to display a list of airports grouped by country using `LazyColumn`. Airports within each country are displayed as `AirportInfoItem` and countries as `CountryItem`.
- Implemented `StationsScreenViewModel.kt` to fetch and manage the state of airports grouped by country. It uses `ObserveAirportsGroupedByCountry` use case.
- Defined `ObserveAirportsGroupedByCountry.kt` use case in `domain:stations` module to provide a flow of airports grouped by country.
- Implemented `ObserveAirportsGroupedByCountryImpl.kt` in the repository layer, which fetches data using `AirportService`, stores it in `AirportsDatasource`, and maps it to the domain model.
- Added Hilt module `ObserveAirportsGroupedByCountryModule.kt` to provide the use case implementation.
- Added `stationsScreen()` and `navigateToStations()` to `FlightsNavGraph.kt` and `TopLevelDestination.kt` for navigation.
- Updated `settings.gradle.kts` and `app/build.gradle.kts` to include the new `ui:stations` and `domain:stations` modules.
- Updated `CacheObservers.kt` to make `mapToDomain` a suspend function.
- Added string resources for the stations screen title.
2025-06-15 23:15:25 +02:00
Adrian Kuta
13348bc52f Refactor: Update package structure and implement bottom navigation
This commit refactors the package structure for Gradle convention plugins and introduces bottom navigation to the application.

Key changes:
- Moved Gradle convention plugin files from `dev.adriankuta.partymania` to `dev.adriankuta.flights`.
- Added `TopLevelDestination.kt` to define top-level navigation destinations with icons, titles, and routes.
- Implemented `FlightsBottomBar` Composable in `FlightsApp.kt` to display a `NavigationBar` with items for each `TopLevelDestination`.
- Updated `FlightsNavGraph.kt`:
    - Renamed from `FlightsNavGraph.kt` to `navigation/FlightsNavGraph.kt`.
    - Added `navigateToTopLevelDestination` extension function for `NavController` to handle navigation to top-level destinations with appropriate `NavOptions`.
- Updated `HomeNavigation.kt`:
    - Added `navigateToHome` extension function for `NavController`.
- Added `strings.xml` for `ui:home` module with `home_screen_title`.
- Ensured Kotlin serialization plugin is correctly applied in `app/build.gradle.kts`.
2025-06-15 22:09:05 +02:00
Adrian Kuta
762c6338de Refactor: Migrate to kotlinx-datetime and implement flight search results display
This commit migrates date handling from `java.time` to `kotlinx-datetime` across various modules. It also introduces the display of flight search results on the home screen.

Key changes:
- Replaced `java.time.LocalDate` and related classes with `kotlinx.datetime.LocalDate` in:
    - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt`
    - `model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImpl.kt`
    - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt`
    - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt`
    - `domain/types/src/main/kotlin/dev/adriankuta/flights/domain/types/TripDate.kt`
    - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/PassengersOptions.kt`
    - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchForm.kt`
    - `domain/search/src/main/kotlin/dev/adriankuta/flights/domain/search/entities/SearchOptions.kt`
- Added `kotlinx-datetime` dependency to `build-logic/convention/src/main/kotlin/dev/adriankuta/partymania/ConfigureKotlinAndroid.kt`.
- Implemented `SearchResults.kt` composable to display flight search results.
- Updated `HomeScreen.kt` to show `SearchResults` when results are available and handle back navigation.
- Modified `HomeScreenViewModel.kt`:
    - Introduced `SearchResultUiState` to manage search result states (Loading, Success, Error, Idle).
    - Updated `search()` function to fetch and expose flight results.
    - Adjusted initial airport list in `homeUiState` to filter for "DUB" and "STN" and ensure destination isn't the same as origin.
- Updated `FlightDomainMapper.kt` to parse date strings into `kotlinx.datetime.LocalDate`.
- Added `HomeUiStatePreviewParameterProvider.kt` and `FlightPreviewParameterProvider.kt` for Compose previews.
- Removed Timber dependency from `ui/home`, `domain/search`, and `domain/types` modules as it's now provided via convention plugin.
2025-06-15 21:30:51 +02:00
60 changed files with 2215 additions and 142 deletions

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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,
),
}

View File

@@ -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)) },
)
}
}
}

View File

@@ -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())

View File

@@ -9,5 +9,4 @@ android {
dependencies {
api(projects.domain.types)
implementation(libs.timber)
}

View File

@@ -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,

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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>
}

View File

@@ -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>>?>
}

View File

@@ -9,5 +9,4 @@ android {
dependencies {
implementation(projects.core.util)
implementation(libs.timber)
}

View File

@@ -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>,
)

View File

@@ -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)
}

View File

@@ -0,0 +1,19 @@
package dev.adriankuta.flights.model.repository.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.adriankuta.flights.domain.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
}

View File

@@ -0,0 +1,19 @@
package dev.adriankuta.flights.model.repository.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.adriankuta.flights.domain.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
}

View File

@@ -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)
}

View File

@@ -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() },
)
}

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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 })
}
},
)
}

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -15,5 +15,4 @@ dependencies {
implementation(projects.domain.search)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.timber)
}

View File

@@ -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,11 +90,16 @@ private fun HomeScreen(
onAdultCountChange = onAdultCountChange,
onTeenCountChange = onTeenCountChange,
onChildCountChange = onChildCountChange,
onSearch = onSearch,
onSearch = {
showSearchResults = true
onSearch()
},
modifier = Modifier.verticalScroll(rememberScrollState()),
)
}
}
}
}
@PreviewDevices
@Composable
@@ -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 = {},

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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))
}

View File

@@ -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()

View File

@@ -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",
),
),
),
),
),
),
),
)
}

View File

@@ -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,
),
),
)
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="home_screen_title">Search Flight</string>
</resources>

View 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)
}

View 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

View 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>

View File

@@ -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,
)
}
}
}
}

View File

@@ -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
}

View File

@@ -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),
)
}
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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()
}
}

View 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>