Compare commits

...

3 Commits

Author SHA1 Message Date
Adrian Kuta
ffcfc1f45b feat: Implement flight search functionality
This commit introduces the ability to search for flights based on user-defined criteria.

Key changes:
- Added `GetFlightsSearchContentUseCase` interface in the domain layer and its implementation `GetFlightsSearchContentUseCaseImpl` in the repository layer to handle flight search logic.
- Implemented `FlightDomainMapper` to map `FlightResponse` from the API to the domain `Flight` model.
- Updated `NetworkModule`:
    - Introduced `ServicesAPI` and `NativeAppsAPI` qualifiers to differentiate between Retrofit instances for different Ryanair APIs.
    - Configured a new Retrofit instance for the `nativeapps.ryanair.com` base URL.
    - Modified `FlightService` to use the `nativeapps.ryanair.com` base URL.
- Updated `FlightService` endpoint to `api/v4/en-gb/Availability`.
- Integrated `GetFlightsSearchContentUseCase` into `HomeScreenViewModel` to trigger flight searches.
- Created `SearchOptions` data class in the domain layer to encapsulate search parameters.
- Added `GetFlightsSearchContentUseCaseModule` to provide the use case implementation via Hilt.
2025-06-14 15:42:07 +02:00
Adrian Kuta
5d9a56d71f feat: Add Jetpack Compose previews for UI components
This commit adds Jetpack Compose `@Preview` annotations to several UI components in the `ui:home` module. This allows developers to visualize these components in Android Studio without needing to run the full application.

Key changes:
- Added `@PreviewDevices` and `FlightsTheme` to `PassengersOptions.kt` and created a `PassengersOptionsPreview` composable function with sample data.
- Added `@PreviewDevices` and `FlightsTheme` to `DatePicker.kt` and created a `DatePickerPreview` composable function with sample data.
- Added `@PreviewDevices` and `FlightsTheme` to `SearchForm.kt` and created a `SearchFormPreview` composable function with sample airport and passenger data.
- Added `@PreviewDevices` and `FlightsTheme` to `AirportDropdown.kt` and created an `AirportDropdownPreview` composable function with sample airport data.
2025-06-14 13:51:01 +02:00
Adrian Kuta
524a64a443 Refactor: Introduce SearchForm and PassengersOptions composables
This commit refactors the HomeScreen by extracting the search form logic into a new `SearchForm` composable and the passenger selection into a `PassengersOptions` composable.

Key changes:
- Created `PassengersOptions.kt` with a composable function to handle adult, teen, and child passenger counts.
- Created `SearchForm.kt` with a composable function that encapsulates airport selection, date picking, passenger options, and the search button.
- Updated `HomeScreen.kt`:
    - Replaced the previous inline form layout with the new `SearchForm` composable.
    - Made the screen content vertically scrollable using `Box` and `verticalScroll`.
    - Passed a new `onSearch` lambda to the `HomeScreenContent`.
- Updated `HomeScreenViewModel.kt`:
    - Modified `updateAdultCount`, `updateTeenCount`, and `updateChildCount` to accept the new count directly instead of a diff.
    - Added a `search()` function (currently logs to Timber).
    - Ensured child count does not exceed adult count when adult count changes.
- Updated `Counter.kt` in `ui/sharedui`:
    - Changed `onCountChange` parameter to `onValueChange` which now receives the new absolute value.
    - Added a `maxVal` parameter to limit the maximum value of the counter.
2025-06-14 13:34:41 +02:00
14 changed files with 585 additions and 113 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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