mirror of
				https://github.com/AdrianKuta/android-challange-adrian-kuta.git
				synced 2025-10-31 06:33:39 +01:00 
			
		
		
		
	Compare commits
	
		
			3 Commits
		
	
	
		
			b23baa587c
			...
			ffcfc1f45b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ffcfc1f45b | ||
|   | 5d9a56d71f | ||
|   | 524a64a443 | 
| @@ -0,0 +1,8 @@ | ||||
| package dev.adriankuta.flights.domain.search | ||||
|  | ||||
| import dev.adriankuta.flights.domain.search.entities.SearchOptions | ||||
| import dev.adriankuta.flights.domain.types.Flight | ||||
|  | ||||
| fun interface GetFlightsSearchContentUseCase { | ||||
|     suspend operator fun invoke(searchOptions: SearchOptions): Flight | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| package dev.adriankuta.flights.domain.search.entities | ||||
|  | ||||
| import dev.adriankuta.flights.domain.types.Airport | ||||
| import java.time.LocalDate | ||||
|  | ||||
| data class SearchOptions( | ||||
|     val origin: Airport.Departure, | ||||
|     val destination: Airport.Arrival, | ||||
|     val date: LocalDate, | ||||
|     val adults: Int, | ||||
|     val teens: Int, | ||||
|     val children: Int, | ||||
| ) | ||||
| @@ -12,7 +12,7 @@ interface FlightService { | ||||
|         "client: android", | ||||
|         "client-version: 3.300.0", | ||||
|     ) | ||||
|     @GET("v4/en-gb/Availability") | ||||
|     @GET("api/v4/en-gb/Availability") | ||||
|     @Suppress("LongParameterList") | ||||
|     suspend fun getFlights( | ||||
|         @Query("dateout") date: String, | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import okhttp3.OkHttpClient | ||||
| import okhttp3.logging.HttpLoggingInterceptor | ||||
| import retrofit2.Retrofit | ||||
| import retrofit2.converter.moshi.MoshiConverterFactory | ||||
| import javax.inject.Qualifier | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| @Module | ||||
| @@ -37,7 +38,8 @@ class NetworkModule { | ||||
|  | ||||
|     @Singleton | ||||
|     @Provides | ||||
|     fun provideRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() | ||||
|     @ServicesAPI | ||||
|     fun provideServicesRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() | ||||
|         // TODO("add URL provided in the task instructions") | ||||
|         .baseUrl("https://services-api.ryanair.com") | ||||
|         .addConverterFactory(MoshiConverterFactory.create(moshi)) | ||||
| @@ -46,16 +48,34 @@ class NetworkModule { | ||||
|  | ||||
|     @Singleton | ||||
|     @Provides | ||||
|     fun provideAirportService(retrofit: Retrofit): AirportService = | ||||
|     @NativeAppsAPI | ||||
|     fun provideNativeAppsRetrofit(client: OkHttpClient, moshi: Moshi): Retrofit = Retrofit.Builder() | ||||
|         // TODO("add URL provided in the task instructions") | ||||
|         .baseUrl("https://nativeapps.ryanair.com") | ||||
|         .addConverterFactory(MoshiConverterFactory.create(moshi)) | ||||
|         .client(client) | ||||
|         .build() | ||||
|  | ||||
|     @Singleton | ||||
|     @Provides | ||||
|     fun provideAirportService(@ServicesAPI retrofit: Retrofit): AirportService = | ||||
|         retrofit.create(AirportService::class.java) | ||||
|  | ||||
|     @Singleton | ||||
|     @Provides | ||||
|     fun provideRoutesService(retrofit: Retrofit): RoutesService = | ||||
|     fun provideRoutesService(@ServicesAPI retrofit: Retrofit): RoutesService = | ||||
|         retrofit.create(RoutesService::class.java) | ||||
|  | ||||
|     @Singleton | ||||
|     @Provides | ||||
|     fun provideFlightService(retrofit: Retrofit): FlightService = | ||||
|     fun provideFlightService(@NativeAppsAPI retrofit: Retrofit): FlightService = | ||||
|         retrofit.create(FlightService::class.java) | ||||
| } | ||||
|  | ||||
| @Qualifier | ||||
| @Retention(AnnotationRetention.BINARY) | ||||
| annotation class ServicesAPI | ||||
|  | ||||
| @Qualifier | ||||
| @Retention(AnnotationRetention.BINARY) | ||||
| annotation class NativeAppsAPI | ||||
|   | ||||
| @@ -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.search.GetFlightsSearchContentUseCase | ||||
| import dev.adriankuta.flights.model.repository.usecases.GetFlightsSearchContentUseCaseImpl | ||||
|  | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| @Suppress("UnnecessaryAbstractClass") | ||||
| internal abstract class GetFlightsSearchContentUseCaseModule { | ||||
|  | ||||
|     @Binds | ||||
|     abstract fun bind( | ||||
|         getFlightsSearchContentUseCaseImpl: GetFlightsSearchContentUseCaseImpl, | ||||
|     ): GetFlightsSearchContentUseCase | ||||
| } | ||||
| @@ -0,0 +1,75 @@ | ||||
| package dev.adriankuta.flights.model.repository.mappers | ||||
|  | ||||
| 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.model.data.api.entities.FlightResponse | ||||
| 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 | ||||
| import dev.adriankuta.model.data.api.entities.TripDate as ApiTripDate | ||||
| import dev.adriankuta.model.data.api.entities.TripFare as ApiTripFare | ||||
| import dev.adriankuta.model.data.api.entities.TripFlight as ApiTripFlight | ||||
|  | ||||
| internal fun FlightResponse.toDomain(): Flight { | ||||
|     return Flight( | ||||
|         currency = currency ?: "", | ||||
|         currPrecision = currPrecision ?: 0, | ||||
|         trips = trips.orEmpty().map { it.toDomain() }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| internal fun ApiTrip.toDomain(): Trip { | ||||
|     return Trip( | ||||
|         dates = dates.orEmpty().map { it.toDomain() }, | ||||
|         origin = origin ?: "", | ||||
|         destination = destination ?: "", | ||||
|     ) | ||||
| } | ||||
|  | ||||
| internal fun ApiTripDate.toDomain(): TripDate { | ||||
|     return TripDate( | ||||
|         dateOut = dateOut ?: "", | ||||
|         flights = flights.orEmpty().map { it.toDomain() }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| internal fun ApiTripFlight.toDomain(): TripFlight { | ||||
|     return TripFlight( | ||||
|         faresLeft = faresLeft ?: 0, | ||||
|         regularFare = regularFare?.toDomain() ?: RegularFare(emptyList()), | ||||
|         flightNumber = flightNumber ?: "", | ||||
|         dateTimes = dateTimes.orEmpty(), | ||||
|         duration = duration ?: "", | ||||
|         segments = segments.orEmpty().map { it.toDomain() }, | ||||
|         operatedBy = operatedBy ?: "", | ||||
|     ) | ||||
| } | ||||
|  | ||||
| internal fun ApiRegularFare.toDomain(): RegularFare { | ||||
|     return RegularFare( | ||||
|         fares = fares.orEmpty().map { it.toDomain() }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| internal fun ApiTripFare.toDomain(): TripFare { | ||||
|     return TripFare( | ||||
|         type = type ?: "", | ||||
|         amount = amount ?: 0.0, | ||||
|         count = count ?: 0, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| internal fun ApiSegment.toDomain(): Segment { | ||||
|     return Segment( | ||||
|         origin = origin ?: "", | ||||
|         destination = destination ?: "", | ||||
|         flightNumber = flightNumber ?: "", | ||||
|         dateTimes = dateTimes.orEmpty(), | ||||
|         duration = duration ?: "", | ||||
|     ) | ||||
| } | ||||
| @@ -0,0 +1,27 @@ | ||||
| package dev.adriankuta.flights.model.repository.usecases | ||||
|  | ||||
| import dev.adriankuta.flights.domain.search.GetFlightsSearchContentUseCase | ||||
| 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( | ||||
|     private val flightService: FlightService, | ||||
| ) : GetFlightsSearchContentUseCase { | ||||
|  | ||||
|     override suspend fun invoke(searchOptions: SearchOptions): Flight { | ||||
|         val result = flightService.getFlights( | ||||
|             origin = searchOptions.origin.code, | ||||
|             destination = searchOptions.destination.code, | ||||
|             date = searchOptions.date.format(DateTimeFormatter.ISO_DATE), | ||||
|             adult = searchOptions.adults, | ||||
|             teen = searchOptions.teens, | ||||
|             child = searchOptions.children, | ||||
|         ) | ||||
|  | ||||
|         return result.toDomain() | ||||
|     } | ||||
| } | ||||
| @@ -1,16 +1,10 @@ | ||||
| package dev.adriankuta.flights.ui.home | ||||
|  | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.ColumnScope | ||||
| import androidx.compose.foundation.layout.IntrinsicSize | ||||
| 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.Box | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.rememberScrollState | ||||
| import androidx.compose.foundation.verticalScroll | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.VerticalDivider | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.ui.Modifier | ||||
| @@ -25,9 +19,7 @@ import dev.adriankuta.flights.domain.types.MacCity | ||||
| import dev.adriankuta.flights.domain.types.Region | ||||
| import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | ||||
| import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | ||||
| import dev.adriankuta.flights.ui.home.components.AirportDropdown | ||||
| import dev.adriankuta.flights.ui.home.components.DatePicker | ||||
| import dev.adriankuta.flights.ui.sharedui.Counter | ||||
| import dev.adriankuta.flights.ui.home.components.SearchForm | ||||
| import java.time.LocalDate | ||||
|  | ||||
| @Composable | ||||
| @@ -44,6 +36,7 @@ internal fun HomeScreen( | ||||
|         onAdultCountChange = viewModel::updateAdultCount, | ||||
|         onTeenCountChange = viewModel::updateTeenCount, | ||||
|         onChildCountChange = viewModel::updateChildCount, | ||||
|         onSearch = viewModel::search, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @@ -56,10 +49,16 @@ private fun HomeScreen( | ||||
|     onAdultCountChange: (Int) -> Unit, | ||||
|     onTeenCountChange: (Int) -> Unit, | ||||
|     onChildCountChange: (Int) -> Unit, | ||||
|     onSearch: () -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Column( | ||||
|         modifier = modifier.padding(16.dp), | ||||
|     val scrollState = rememberScrollState() | ||||
|     Box( | ||||
|         modifier = modifier | ||||
|             .padding(16.dp) | ||||
|             .verticalScroll( | ||||
|                 state = scrollState, | ||||
|             ), | ||||
|     ) { | ||||
|         when (uiState) { | ||||
|             is HomeUiState.Error -> Text("Error") | ||||
| @@ -72,90 +71,12 @@ private fun HomeScreen( | ||||
|                 onAdultCountChange = onAdultCountChange, | ||||
|                 onTeenCountChange = onTeenCountChange, | ||||
|                 onChildCountChange = onChildCountChange, | ||||
|                 onSearch = onSearch, | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| private fun ColumnScope.SearchForm( | ||||
|     uiState: HomeUiState.Success, | ||||
|     onOriginAirportSelect: (AirportInfo) -> Unit, | ||||
|     onDestinationAirportSelect: (AirportInfo) -> Unit, | ||||
|     onDateSelect: (LocalDate) -> Unit, | ||||
|     onAdultCountChange: (Int) -> Unit, | ||||
|     onTeenCountChange: (Int) -> Unit, | ||||
|     onChildCountChange: (Int) -> Unit, | ||||
| ) { | ||||
|     AirportDropdown( | ||||
|         label = "Origin Airport", | ||||
|         airports = uiState.originAirports, | ||||
|         selectedAirport = uiState.selectedOriginAirport, | ||||
|         onAirportSelect = onOriginAirportSelect, | ||||
|         modifier = Modifier.fillMaxWidth(), | ||||
|     ) | ||||
|  | ||||
|     Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|     AirportDropdown( | ||||
|         label = "Destination Airport", | ||||
|         airports = uiState.destinationAirports, | ||||
|         selectedAirport = uiState.selectedDestinationAirport, | ||||
|         onAirportSelect = onDestinationAirportSelect, | ||||
|         modifier = Modifier.fillMaxWidth(), | ||||
|         enabled = uiState.selectedOriginAirport != null, | ||||
|     ) | ||||
|  | ||||
|     Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|     DatePicker( | ||||
|         label = "Departure Date", | ||||
|         selectedDate = uiState.selectedDate, | ||||
|         onDateSelect = onDateSelect, | ||||
|         modifier = Modifier.fillMaxWidth(), | ||||
|         enabled = uiState.selectedDestinationAirport != null, | ||||
|     ) | ||||
|  | ||||
|     Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|     Text("Passengers") | ||||
|  | ||||
|     Spacer(modifier = Modifier.height(8.dp)) | ||||
|  | ||||
|     Row( | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .height(intrinsicSize = IntrinsicSize.Min), | ||||
|         horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||
|     ) { | ||||
|         Counter( | ||||
|             value = uiState.passengers.adultCount, | ||||
|             onCountChange = onAdultCountChange, | ||||
|             label = "Adults", | ||||
|             modifier = Modifier.weight(1f), | ||||
|             minVal = 1, | ||||
|         ) | ||||
|  | ||||
|         VerticalDivider() | ||||
|  | ||||
|         Counter( | ||||
|             value = uiState.passengers.teenCount, | ||||
|             onCountChange = onTeenCountChange, | ||||
|             label = "Teens", | ||||
|             modifier = Modifier.weight(1f), | ||||
|         ) | ||||
|  | ||||
|         VerticalDivider() | ||||
|  | ||||
|         Counter( | ||||
|             value = uiState.passengers.childCount, | ||||
|             onCountChange = onChildCountChange, | ||||
|             label = "Children", | ||||
|             modifier = Modifier.weight(1f), | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @PreviewDevices | ||||
| @Composable | ||||
| private fun HomeScreenLoadingPreview() { | ||||
| @@ -168,6 +89,7 @@ private fun HomeScreenLoadingPreview() { | ||||
|             onAdultCountChange = {}, | ||||
|             onTeenCountChange = {}, | ||||
|             onChildCountChange = {}, | ||||
|             onSearch = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -222,6 +144,7 @@ private fun HomeScreenSuccessPreview() { | ||||
|             onAdultCountChange = {}, | ||||
|             onTeenCountChange = {}, | ||||
|             onChildCountChange = {}, | ||||
|             onSearch = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -5,7 +5,10 @@ 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.search.GetFlightsSearchContentUseCase | ||||
| 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 kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| @@ -13,12 +16,15 @@ import kotlinx.coroutines.flow.SharingStarted | ||||
| 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 javax.inject.Inject | ||||
|  | ||||
| @HiltViewModel | ||||
| class HomeScreenViewModel @Inject constructor( | ||||
|     private val observeAirportsUseCase: ObserveAirportsUseCase, | ||||
|     observeAirportsUseCase: ObserveAirportsUseCase, | ||||
|     private val getFlightsSearchContentUseCase: GetFlightsSearchContentUseCase, | ||||
| ) : ViewModel() { | ||||
|  | ||||
|     private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null) | ||||
| @@ -71,19 +77,41 @@ class HomeScreenViewModel @Inject constructor( | ||||
|         selectedDate.value = date | ||||
|     } | ||||
|  | ||||
|     fun updateAdultCount(diff: Int) { | ||||
|         val newCount = adultCount.value + diff | ||||
|         adultCount.value = newCount | ||||
|     fun updateAdultCount(count: Int) { | ||||
|         adultCount.value = count | ||||
|         childCount.value = childCount.value.coerceAtMost(count) | ||||
|     } | ||||
|  | ||||
|     fun updateTeenCount(diff: Int) { | ||||
|         val newCount = teenCount.value + diff | ||||
|         teenCount.value = newCount | ||||
|     fun updateTeenCount(count: Int) { | ||||
|         teenCount.value = count | ||||
|     } | ||||
|  | ||||
|     fun updateChildCount(diff: Int) { | ||||
|         val newCount = childCount.value + diff | ||||
|         childCount.value = newCount | ||||
|     fun updateChildCount(count: Int) { | ||||
|         childCount.value = count | ||||
|     } | ||||
|  | ||||
|     fun search() { | ||||
|         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") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -15,6 +15,13 @@ import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Modifier | ||||
| 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.designsystem.theme.FlightsTheme | ||||
| import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices | ||||
|  | ||||
| @OptIn(ExperimentalMaterial3Api::class) | ||||
| @Composable | ||||
| @@ -64,3 +71,75 @@ fun AirportDropdown( | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @OptIn(ExperimentalMaterial3Api::class) | ||||
| @PreviewDevices | ||||
| @Composable | ||||
| @Suppress("LongMethod") | ||||
| private fun AirportDropdownPreview() { | ||||
|     val sampleAirports = 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", | ||||
|                 name = "Poland", | ||||
|             ), | ||||
|             country = Country( | ||||
|                 code = "PL", | ||||
|                 name = "Poland", | ||||
|                 currencyCode = "PLN", | ||||
|             ), | ||||
|             coordinates = Coordinates( | ||||
|                 latitude = 52.1657, | ||||
|                 longitude = 20.9671, | ||||
|             ), | ||||
|         ), | ||||
|         AirportInfo( | ||||
|             code = "LHR", | ||||
|             name = "London Heathrow", | ||||
|             seoName = "london", | ||||
|             isBase = false, | ||||
|             timeZone = "Europe/London", | ||||
|             city = City( | ||||
|                 code = "LON", | ||||
|                 name = "London", | ||||
|             ), | ||||
|             macCity = MacCity( | ||||
|                 macCode = "LON", | ||||
|             ), | ||||
|             region = Region( | ||||
|                 code = "UK", | ||||
|                 name = "United Kingdom", | ||||
|             ), | ||||
|             country = Country( | ||||
|                 code = "UK", | ||||
|                 name = "United Kingdom", | ||||
|                 currencyCode = "GBP", | ||||
|             ), | ||||
|             coordinates = Coordinates( | ||||
|                 latitude = 51.4700, | ||||
|                 longitude = -0.4543, | ||||
|             ), | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     FlightsTheme { | ||||
|         AirportDropdown( | ||||
|             label = "Departure Airport", | ||||
|             airports = sampleAirports, | ||||
|             selectedAirport = sampleAirports.first(), | ||||
|             onAirportSelect = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -20,6 +20,8 @@ import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalFocusManager | ||||
| 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 | ||||
| @@ -152,3 +154,19 @@ class FutureSelectableDates : SelectableDates { | ||||
|         return year >= now.year | ||||
|     } | ||||
| } | ||||
|  | ||||
| @OptIn(ExperimentalMaterial3Api::class) | ||||
| @PreviewDevices | ||||
| @Composable | ||||
| private fun DatePickerPreview() { | ||||
|     val today = LocalDate.now() | ||||
|     val futureDate = today.plusDays(7) // A week from today | ||||
|  | ||||
|     FlightsTheme { | ||||
|         DatePicker( | ||||
|             label = "Departure Date", | ||||
|             selectedDate = futureDate, | ||||
|             onDateSelect = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,86 @@ | ||||
| package dev.adriankuta.flights.ui.home.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.IntrinsicSize | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.material3.VerticalDivider | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.unit.dp | ||||
| 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 dev.adriankuta.flights.ui.sharedui.Counter | ||||
|  | ||||
| @Composable | ||||
| internal fun PassengersOptions( | ||||
|     uiState: HomeUiState.Success, | ||||
|     onAdultCountChange: (Int) -> Unit, | ||||
|     onTeenCountChange: (Int) -> Unit, | ||||
|     onChildCountChange: (Int) -> Unit, | ||||
| ) { | ||||
|     Row( | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .height(intrinsicSize = IntrinsicSize.Min), | ||||
|         horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||
|     ) { | ||||
|         Counter( | ||||
|             value = uiState.passengers.adultCount, | ||||
|             onValueChange = onAdultCountChange, | ||||
|             label = "Adults", | ||||
|             modifier = Modifier.weight(1f), | ||||
|             minVal = 1, | ||||
|         ) | ||||
|  | ||||
|         VerticalDivider() | ||||
|  | ||||
|         Counter( | ||||
|             value = uiState.passengers.teenCount, | ||||
|             onValueChange = onTeenCountChange, | ||||
|             label = "Teens", | ||||
|             modifier = Modifier.weight(1f), | ||||
|         ) | ||||
|  | ||||
|         VerticalDivider() | ||||
|  | ||||
|         Counter( | ||||
|             value = uiState.passengers.childCount, | ||||
|             onValueChange = onChildCountChange, | ||||
|             label = "Children", | ||||
|             modifier = Modifier.weight(1f), | ||||
|             maxVal = uiState.passengers.adultCount, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @PreviewDevices | ||||
| @Composable | ||||
| private fun PassengersOptionsPreview() { | ||||
|     val samplePassengersState = PassengersState( | ||||
|         adultCount = 2, | ||||
|         teenCount = 1, | ||||
|         childCount = 1, | ||||
|     ) | ||||
|  | ||||
|     val sampleUiState = HomeUiState.Success( | ||||
|         originAirports = emptyList(), | ||||
|         destinationAirports = emptyList(), | ||||
|         selectedOriginAirport = null, | ||||
|         selectedDestinationAirport = null, | ||||
|         selectedDate = java.time.LocalDate.now(), | ||||
|         passengers = samplePassengersState, | ||||
|     ) | ||||
|  | ||||
|     FlightsTheme { | ||||
|         PassengersOptions( | ||||
|             uiState = sampleUiState, | ||||
|             onAdultCountChange = {}, | ||||
|             onTeenCountChange = {}, | ||||
|             onChildCountChange = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,174 @@ | ||||
| package dev.adriankuta.flights.ui.home.components | ||||
|  | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.material3.Button | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.unit.dp | ||||
| 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.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 | ||||
|  | ||||
| @Composable | ||||
| internal fun SearchForm( | ||||
|     uiState: HomeUiState.Success, | ||||
|     onOriginAirportSelect: (AirportInfo) -> Unit, | ||||
|     onDestinationAirportSelect: (AirportInfo) -> Unit, | ||||
|     onDateSelect: (LocalDate) -> Unit, | ||||
|     onAdultCountChange: (Int) -> Unit, | ||||
|     onTeenCountChange: (Int) -> Unit, | ||||
|     onChildCountChange: (Int) -> Unit, | ||||
|     onSearch: () -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     Column(modifier) { | ||||
|         AirportDropdown( | ||||
|             label = "Origin Airport", | ||||
|             airports = uiState.originAirports, | ||||
|             selectedAirport = uiState.selectedOriginAirport, | ||||
|             onAirportSelect = onOriginAirportSelect, | ||||
|             modifier = Modifier.fillMaxWidth(), | ||||
|         ) | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|         AirportDropdown( | ||||
|             label = "Destination Airport", | ||||
|             airports = uiState.destinationAirports, | ||||
|             selectedAirport = uiState.selectedDestinationAirport, | ||||
|             onAirportSelect = onDestinationAirportSelect, | ||||
|             modifier = Modifier.fillMaxWidth(), | ||||
|             enabled = uiState.selectedOriginAirport != null, | ||||
|         ) | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|         DatePicker( | ||||
|             label = "Departure Date", | ||||
|             selectedDate = uiState.selectedDate, | ||||
|             onDateSelect = onDateSelect, | ||||
|             modifier = Modifier.fillMaxWidth(), | ||||
|             enabled = uiState.selectedDestinationAirport != null, | ||||
|         ) | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(16.dp)) | ||||
|  | ||||
|         Text("Passengers") | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(8.dp)) | ||||
|  | ||||
|         PassengersOptions(uiState, onAdultCountChange, onTeenCountChange, onChildCountChange) | ||||
|  | ||||
|         Spacer(modifier = Modifier.height(24.dp)) | ||||
|  | ||||
|         Button( | ||||
|             onClick = onSearch, | ||||
|             modifier = Modifier.fillMaxWidth(), | ||||
|             enabled = uiState.selectedOriginAirport != null && uiState.selectedDestinationAirport != null, | ||||
|         ) { | ||||
|             Text("Search Flights") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @PreviewDevices | ||||
| @Composable | ||||
| @Suppress("LongMethod") | ||||
| private fun SearchFormPreview() { | ||||
|     val sampleAirports = 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", | ||||
|                 name = "Poland", | ||||
|             ), | ||||
|             country = Country( | ||||
|                 code = "PL", | ||||
|                 name = "Poland", | ||||
|                 currencyCode = "PLN", | ||||
|             ), | ||||
|             coordinates = Coordinates( | ||||
|                 latitude = 52.1657, | ||||
|                 longitude = 20.9671, | ||||
|             ), | ||||
|         ), | ||||
|         AirportInfo( | ||||
|             code = "LHR", | ||||
|             name = "London Heathrow", | ||||
|             seoName = "london", | ||||
|             isBase = false, | ||||
|             timeZone = "Europe/London", | ||||
|             city = City( | ||||
|                 code = "LON", | ||||
|                 name = "London", | ||||
|             ), | ||||
|             macCity = MacCity( | ||||
|                 macCode = "LON", | ||||
|             ), | ||||
|             region = Region( | ||||
|                 code = "UK", | ||||
|                 name = "United Kingdom", | ||||
|             ), | ||||
|             country = Country( | ||||
|                 code = "UK", | ||||
|                 name = "United Kingdom", | ||||
|                 currencyCode = "GBP", | ||||
|             ), | ||||
|             coordinates = Coordinates( | ||||
|                 latitude = 51.4700, | ||||
|                 longitude = -0.4543, | ||||
|             ), | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|     val samplePassengersState = PassengersState( | ||||
|         adultCount = 2, | ||||
|         teenCount = 1, | ||||
|         childCount = 1, | ||||
|     ) | ||||
|  | ||||
|     val sampleUiState = HomeUiState.Success( | ||||
|         originAirports = sampleAirports, | ||||
|         destinationAirports = sampleAirports.drop(1), | ||||
|         selectedOriginAirport = sampleAirports.first(), | ||||
|         selectedDestinationAirport = sampleAirports.last(), | ||||
|         selectedDate = LocalDate.now().plusDays(7), | ||||
|         passengers = samplePassengersState, | ||||
|     ) | ||||
|  | ||||
|     FlightsTheme { | ||||
|         SearchForm( | ||||
|             uiState = sampleUiState, | ||||
|             onOriginAirportSelect = {}, | ||||
|             onDestinationAirportSelect = {}, | ||||
|             onDateSelect = {}, | ||||
|             onAdultCountChange = {}, | ||||
|             onTeenCountChange = {}, | ||||
|             onChildCountChange = {}, | ||||
|             onSearch = {}, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -34,10 +34,11 @@ import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme | ||||
| @Composable | ||||
| fun Counter( | ||||
|     value: Int, | ||||
|     onCountChange: (diff: Int) -> Unit, | ||||
|     onValueChange: (newValue: Int) -> Unit, | ||||
|     modifier: Modifier = Modifier, | ||||
|     label: String? = null, | ||||
|     minVal: Int = 0, | ||||
|     maxVal: Int = Int.MAX_VALUE, | ||||
| ) { | ||||
|     val view = LocalView.current | ||||
|  | ||||
| @@ -84,7 +85,7 @@ fun Counter( | ||||
|                 modifier = Modifier.weight(1f), | ||||
|                 onClick = { | ||||
|                     view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) | ||||
|                     onCountChange(-1) | ||||
|                     onValueChange(value - 1) | ||||
|                 }, | ||||
|                 shape = MaterialTheme.shapes.medium, | ||||
|                 enabled = value > minVal, | ||||
| @@ -99,10 +100,10 @@ fun Counter( | ||||
|                 modifier = Modifier.weight(1f), | ||||
|                 onClick = { | ||||
|                     view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) | ||||
|                     onCountChange(1) | ||||
|                     onValueChange(value + 1) | ||||
|                 }, | ||||
|                 shape = MaterialTheme.shapes.medium, | ||||
|                 enabled = value < 20, | ||||
|                 enabled = value < maxVal, | ||||
|             ) { | ||||
|                 Text( | ||||
|                     text = "+", | ||||
| @@ -124,7 +125,8 @@ private fun CounterPreview() { | ||||
|         Counter( | ||||
|             value = tapCounter, | ||||
|             label = "Taps", | ||||
|             onCountChange = { tapCounter += it }, | ||||
|             onValueChange = { tapCounter += it }, | ||||
|             maxVal = 20, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user