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