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