mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-09-14 21:04:22 +02: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: android",
|
||||||
"client-version: 3.300.0",
|
"client-version: 3.300.0",
|
||||||
)
|
)
|
||||||
@GET("v4/en-gb/Availability")
|
@GET("api/v4/en-gb/Availability")
|
||||||
@Suppress("LongParameterList")
|
@Suppress("LongParameterList")
|
||||||
suspend fun getFlights(
|
suspend fun getFlights(
|
||||||
@Query("dateout") date: String,
|
@Query("dateout") date: String,
|
||||||
|
@@ -12,6 +12,7 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
|
import javax.inject.Qualifier
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -37,7 +38,8 @@ class NetworkModule {
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@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")
|
// TODO("add URL provided in the task instructions")
|
||||||
.baseUrl("https://services-api.ryanair.com")
|
.baseUrl("https://services-api.ryanair.com")
|
||||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||||
@@ -46,16 +48,34 @@ class NetworkModule {
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@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)
|
retrofit.create(AirportService::class.java)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun provideRoutesService(retrofit: Retrofit): RoutesService =
|
fun provideRoutesService(@ServicesAPI retrofit: Retrofit): RoutesService =
|
||||||
retrofit.create(RoutesService::class.java)
|
retrofit.create(RoutesService::class.java)
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun provideFlightService(retrofit: Retrofit): FlightService =
|
fun provideFlightService(@NativeAppsAPI retrofit: Retrofit): FlightService =
|
||||||
retrofit.create(FlightService::class.java)
|
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
|
package dev.adriankuta.flights.ui.home
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
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.padding
|
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.Text
|
||||||
import androidx.compose.material3.VerticalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
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.domain.types.Region
|
||||||
import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
||||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||||
import dev.adriankuta.flights.ui.home.components.AirportDropdown
|
import dev.adriankuta.flights.ui.home.components.SearchForm
|
||||||
import dev.adriankuta.flights.ui.home.components.DatePicker
|
|
||||||
import dev.adriankuta.flights.ui.sharedui.Counter
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,6 +36,7 @@ internal fun HomeScreen(
|
|||||||
onAdultCountChange = viewModel::updateAdultCount,
|
onAdultCountChange = viewModel::updateAdultCount,
|
||||||
onTeenCountChange = viewModel::updateTeenCount,
|
onTeenCountChange = viewModel::updateTeenCount,
|
||||||
onChildCountChange = viewModel::updateChildCount,
|
onChildCountChange = viewModel::updateChildCount,
|
||||||
|
onSearch = viewModel::search,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +49,16 @@ private fun HomeScreen(
|
|||||||
onAdultCountChange: (Int) -> Unit,
|
onAdultCountChange: (Int) -> Unit,
|
||||||
onTeenCountChange: (Int) -> Unit,
|
onTeenCountChange: (Int) -> Unit,
|
||||||
onChildCountChange: (Int) -> Unit,
|
onChildCountChange: (Int) -> Unit,
|
||||||
|
onSearch: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
val scrollState = rememberScrollState()
|
||||||
modifier = modifier.padding(16.dp),
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(
|
||||||
|
state = scrollState,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
when (uiState) {
|
when (uiState) {
|
||||||
is HomeUiState.Error -> Text("Error")
|
is HomeUiState.Error -> Text("Error")
|
||||||
@@ -72,90 +71,12 @@ private fun HomeScreen(
|
|||||||
onAdultCountChange = onAdultCountChange,
|
onAdultCountChange = onAdultCountChange,
|
||||||
onTeenCountChange = onTeenCountChange,
|
onTeenCountChange = onTeenCountChange,
|
||||||
onChildCountChange = onChildCountChange,
|
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
|
@PreviewDevices
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeScreenLoadingPreview() {
|
private fun HomeScreenLoadingPreview() {
|
||||||
@@ -168,6 +89,7 @@ private fun HomeScreenLoadingPreview() {
|
|||||||
onAdultCountChange = {},
|
onAdultCountChange = {},
|
||||||
onTeenCountChange = {},
|
onTeenCountChange = {},
|
||||||
onChildCountChange = {},
|
onChildCountChange = {},
|
||||||
|
onSearch = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +144,7 @@ private fun HomeScreenSuccessPreview() {
|
|||||||
onAdultCountChange = {},
|
onAdultCountChange = {},
|
||||||
onTeenCountChange = {},
|
onTeenCountChange = {},
|
||||||
onChildCountChange = {},
|
onChildCountChange = {},
|
||||||
|
onSearch = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dev.adriankuta.flights.core.util.Result
|
import dev.adriankuta.flights.core.util.Result
|
||||||
import dev.adriankuta.flights.core.util.asResult
|
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.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.AirportInfo
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -13,12 +16,15 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HomeScreenViewModel @Inject constructor(
|
class HomeScreenViewModel @Inject constructor(
|
||||||
private val observeAirportsUseCase: ObserveAirportsUseCase,
|
observeAirportsUseCase: ObserveAirportsUseCase,
|
||||||
|
private val getFlightsSearchContentUseCase: GetFlightsSearchContentUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
||||||
@@ -71,19 +77,41 @@ class HomeScreenViewModel @Inject constructor(
|
|||||||
selectedDate.value = date
|
selectedDate.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateAdultCount(diff: Int) {
|
fun updateAdultCount(count: Int) {
|
||||||
val newCount = adultCount.value + diff
|
adultCount.value = count
|
||||||
adultCount.value = newCount
|
childCount.value = childCount.value.coerceAtMost(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTeenCount(diff: Int) {
|
fun updateTeenCount(count: Int) {
|
||||||
val newCount = teenCount.value + diff
|
teenCount.value = count
|
||||||
teenCount.value = newCount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateChildCount(diff: Int) {
|
fun updateChildCount(count: Int) {
|
||||||
val newCount = childCount.value + diff
|
childCount.value = count
|
||||||
childCount.value = newCount
|
}
|
||||||
|
|
||||||
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dev.adriankuta.flights.domain.types.AirportInfo
|
import dev.adriankuta.flights.domain.types.AirportInfo
|
||||||
|
import dev.adriankuta.flights.domain.types.City
|
||||||
|
import dev.adriankuta.flights.domain.types.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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@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.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
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.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -152,3 +154,19 @@ class FutureSelectableDates : SelectableDates {
|
|||||||
return year >= now.year
|
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
|
@Composable
|
||||||
fun Counter(
|
fun Counter(
|
||||||
value: Int,
|
value: Int,
|
||||||
onCountChange: (diff: Int) -> Unit,
|
onValueChange: (newValue: Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
label: String? = null,
|
label: String? = null,
|
||||||
minVal: Int = 0,
|
minVal: Int = 0,
|
||||||
|
maxVal: Int = Int.MAX_VALUE,
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ fun Counter(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = {
|
onClick = {
|
||||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
onCountChange(-1)
|
onValueChange(value - 1)
|
||||||
},
|
},
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
enabled = value > minVal,
|
enabled = value > minVal,
|
||||||
@@ -99,10 +100,10 @@ fun Counter(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onClick = {
|
onClick = {
|
||||||
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||||
onCountChange(1)
|
onValueChange(value + 1)
|
||||||
},
|
},
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
enabled = value < 20,
|
enabled = value < maxVal,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "+",
|
text = "+",
|
||||||
@@ -124,7 +125,8 @@ private fun CounterPreview() {
|
|||||||
Counter(
|
Counter(
|
||||||
value = tapCounter,
|
value = tapCounter,
|
||||||
label = "Taps",
|
label = "Taps",
|
||||||
onCountChange = { tapCounter += it },
|
onValueChange = { tapCounter += it },
|
||||||
|
maxVal = 20,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user