mirror of
https://github.com/AdrianKuta/android-challange-adrian-kuta.git
synced 2025-07-01 15:07:59 +02:00
Refactor: Migrate to kotlinx-datetime and implement flight search results display
This commit migrates date handling from `java.time` to `kotlinx-datetime` across various modules. It also introduces the display of flight search results on the home screen. Key changes: - Replaced `java.time.LocalDate` and related classes with `kotlinx.datetime.LocalDate` in: - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt` - `model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImpl.kt` - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt` - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt` - `domain/types/src/main/kotlin/dev/adriankuta/flights/domain/types/TripDate.kt` - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/PassengersOptions.kt` - `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchForm.kt` - `domain/search/src/main/kotlin/dev/adriankuta/flights/domain/search/entities/SearchOptions.kt` - Added `kotlinx-datetime` dependency to `build-logic/convention/src/main/kotlin/dev/adriankuta/partymania/ConfigureKotlinAndroid.kt`. - Implemented `SearchResults.kt` composable to display flight search results. - Updated `HomeScreen.kt` to show `SearchResults` when results are available and handle back navigation. - Modified `HomeScreenViewModel.kt`: - Introduced `SearchResultUiState` to manage search result states (Loading, Success, Error, Idle). - Updated `search()` function to fetch and expose flight results. - Adjusted initial airport list in `homeUiState` to filter for "DUB" and "STN" and ensure destination isn't the same as origin. - Updated `FlightDomainMapper.kt` to parse date strings into `kotlinx.datetime.LocalDate`. - Added `HomeUiStatePreviewParameterProvider.kt` and `FlightPreviewParameterProvider.kt` for Compose previews. - Removed Timber dependency from `ui/home`, `domain/search`, and `domain/types` modules as it's now provided via convention plugin.
This commit is contained in:
@ -61,6 +61,7 @@ internal fun Project.configureKotlinAndroid(
|
||||
dependencies {
|
||||
"implementation"(libs.findLibrary("androidx.core.ktx").get())
|
||||
"implementation"(libs.findLibrary("kotlinx.coroutines.android").get())
|
||||
"implementation"(libs.findLibrary("kotlinx.datetime").get())
|
||||
"implementation"(libs.findLibrary("timber").get())
|
||||
|
||||
"coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())
|
||||
|
@ -9,5 +9,4 @@ android {
|
||||
|
||||
dependencies {
|
||||
api(projects.domain.types)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
package dev.adriankuta.flights.domain.search.entities
|
||||
|
||||
import dev.adriankuta.flights.domain.types.Airport
|
||||
import java.time.LocalDate
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
data class SearchOptions(
|
||||
val origin: Airport.Departure,
|
||||
|
@ -9,5 +9,4 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.util)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
package dev.adriankuta.flights.domain.types
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
data class TripDate(
|
||||
val dateOut: String,
|
||||
val dateOut: LocalDate?,
|
||||
val flights: List<TripFlight>,
|
||||
)
|
||||
|
@ -8,6 +8,7 @@ import dev.adriankuta.flights.domain.types.TripDate
|
||||
import dev.adriankuta.flights.domain.types.TripFare
|
||||
import dev.adriankuta.flights.domain.types.TripFlight
|
||||
import dev.adriankuta.model.data.api.entities.FlightResponse
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import dev.adriankuta.model.data.api.entities.RegularFare as ApiRegularFare
|
||||
import dev.adriankuta.model.data.api.entities.Segment as ApiSegment
|
||||
import dev.adriankuta.model.data.api.entities.Trip as ApiTrip
|
||||
@ -33,7 +34,7 @@ internal fun ApiTrip.toDomain(): Trip {
|
||||
|
||||
internal fun ApiTripDate.toDomain(): TripDate {
|
||||
return TripDate(
|
||||
dateOut = dateOut ?: "",
|
||||
dateOut = dateOut?.let { LocalDateTime.parse(it).date },
|
||||
flights = flights.orEmpty().map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ import dev.adriankuta.flights.domain.search.entities.SearchOptions
|
||||
import dev.adriankuta.flights.domain.types.Flight
|
||||
import dev.adriankuta.flights.model.repository.mappers.toDomain
|
||||
import dev.adriankuta.model.data.api.FlightService
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
|
||||
@ -16,7 +15,7 @@ internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
|
||||
val result = flightService.getFlights(
|
||||
origin = searchOptions.origin.code,
|
||||
destination = searchOptions.destination.code,
|
||||
date = searchOptions.date.format(DateTimeFormatter.ISO_DATE),
|
||||
date = searchOptions.date.toString(),
|
||||
adult = searchOptions.adults,
|
||||
teen = searchOptions.teens,
|
||||
child = searchOptions.children,
|
||||
|
@ -15,5 +15,4 @@ dependencies {
|
||||
implementation(projects.domain.search)
|
||||
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package dev.adriankuta.flights.ui.home
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@ -7,29 +8,39 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dev.adriankuta.flights.domain.types.AirportInfo
|
||||
import dev.adriankuta.flights.domain.types.City
|
||||
import dev.adriankuta.flights.domain.types.Coordinates
|
||||
import dev.adriankuta.flights.domain.types.Country
|
||||
import dev.adriankuta.flights.domain.types.MacCity
|
||||
import dev.adriankuta.flights.domain.types.Region
|
||||
import dev.adriankuta.flights.domain.types.Flight
|
||||
import dev.adriankuta.flights.domain.types.RegularFare
|
||||
import dev.adriankuta.flights.domain.types.Segment
|
||||
import dev.adriankuta.flights.domain.types.Trip
|
||||
import dev.adriankuta.flights.domain.types.TripDate
|
||||
import dev.adriankuta.flights.domain.types.TripFare
|
||||
import dev.adriankuta.flights.domain.types.TripFlight
|
||||
import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||
import dev.adriankuta.flights.ui.home.components.SearchForm
|
||||
import java.time.LocalDate
|
||||
import dev.adriankuta.flights.ui.home.components.SearchResults
|
||||
import dev.adriankuta.flights.ui.home.sample.HomeUiStatePreviewParameterProvider
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
@Composable
|
||||
internal fun HomeScreen(
|
||||
viewModel: HomeScreenViewModel = hiltViewModel(),
|
||||
) {
|
||||
val homeUiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val searchResults by viewModel.searchResultUiState.collectAsStateWithLifecycle()
|
||||
|
||||
HomeScreen(
|
||||
uiState = homeUiState,
|
||||
searchResultsUiState = searchResults,
|
||||
onOriginAirportSelect = viewModel::selectOriginAirport,
|
||||
onDestinationAirportSelect = viewModel::selectDestinationAirport,
|
||||
onDateSelect = viewModel::selectDate,
|
||||
@ -43,6 +54,7 @@ internal fun HomeScreen(
|
||||
@Composable
|
||||
private fun HomeScreen(
|
||||
uiState: HomeUiState,
|
||||
searchResultsUiState: SearchResultUiState,
|
||||
onOriginAirportSelect: (AirportInfo) -> Unit,
|
||||
onDestinationAirportSelect: (AirportInfo) -> Unit,
|
||||
onDateSelect: (LocalDate) -> Unit,
|
||||
@ -52,27 +64,39 @@ private fun HomeScreen(
|
||||
onSearch: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showSearchResults by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = showSearchResults) {
|
||||
showSearchResults = false
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(16.dp)
|
||||
.verticalScroll(
|
||||
state = scrollState,
|
||||
),
|
||||
.padding(16.dp),
|
||||
) {
|
||||
when (uiState) {
|
||||
is HomeUiState.Error -> Text("Error")
|
||||
HomeUiState.Loading -> Text("Loading")
|
||||
is HomeUiState.Success -> SearchForm(
|
||||
uiState = uiState,
|
||||
onOriginAirportSelect = onOriginAirportSelect,
|
||||
onDestinationAirportSelect = onDestinationAirportSelect,
|
||||
onDateSelect = onDateSelect,
|
||||
onAdultCountChange = onAdultCountChange,
|
||||
onTeenCountChange = onTeenCountChange,
|
||||
onChildCountChange = onChildCountChange,
|
||||
onSearch = onSearch,
|
||||
)
|
||||
is HomeUiState.Success -> if (showSearchResults) {
|
||||
SearchResults(
|
||||
uiState = searchResultsUiState,
|
||||
)
|
||||
} else {
|
||||
SearchForm(
|
||||
uiState = uiState,
|
||||
onOriginAirportSelect = onOriginAirportSelect,
|
||||
onDestinationAirportSelect = onDestinationAirportSelect,
|
||||
onDateSelect = onDateSelect,
|
||||
onAdultCountChange = onAdultCountChange,
|
||||
onTeenCountChange = onTeenCountChange,
|
||||
onChildCountChange = onChildCountChange,
|
||||
onSearch = {
|
||||
showSearchResults = true
|
||||
onSearch()
|
||||
},
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -83,6 +107,7 @@ private fun HomeScreenLoadingPreview() {
|
||||
FlightsTheme {
|
||||
HomeScreen(
|
||||
uiState = HomeUiState.Loading,
|
||||
searchResultsUiState = SearchResultUiState.Idle,
|
||||
onOriginAirportSelect = {},
|
||||
onDestinationAirportSelect = {},
|
||||
onDateSelect = {},
|
||||
@ -96,48 +121,83 @@ private fun HomeScreenLoadingPreview() {
|
||||
|
||||
@PreviewDevices
|
||||
@Composable
|
||||
private fun HomeScreenSuccessPreview() {
|
||||
private fun HomeScreenSuccessPreview(
|
||||
@PreviewParameter(HomeUiStatePreviewParameterProvider::class) homeUiState: HomeUiState,
|
||||
) {
|
||||
FlightsTheme {
|
||||
val mockAirports = listOf(
|
||||
AirportInfo(
|
||||
code = "WAW",
|
||||
name = "Warsaw Chopin Airport",
|
||||
seoName = "warsaw",
|
||||
isBase = true,
|
||||
timeZone = "Europe/Warsaw",
|
||||
city = City("WAW", "Warsaw"),
|
||||
macCity = MacCity("WARSAW"),
|
||||
region = Region("WARSAW_PL", "Warsaw"),
|
||||
country = Country("PL", "Poland", "PLN"),
|
||||
coordinates = Coordinates(52.1657, 20.9671),
|
||||
),
|
||||
AirportInfo(
|
||||
code = "KRK",
|
||||
name = "Krakow Airport",
|
||||
seoName = "krakow",
|
||||
isBase = true,
|
||||
timeZone = "Europe/Warsaw",
|
||||
city = City("KRK", "Krakow"),
|
||||
macCity = MacCity("KRAKOW"),
|
||||
region = Region("KRAKOW_PL", "Krakow"),
|
||||
country = Country("PL", "Poland", "PLN"),
|
||||
coordinates = Coordinates(50.0777, 19.7848),
|
||||
),
|
||||
)
|
||||
|
||||
HomeScreen(
|
||||
uiState = HomeUiState.Success(
|
||||
originAirports = mockAirports,
|
||||
destinationAirports = mockAirports,
|
||||
selectedOriginAirport = mockAirports.first(),
|
||||
selectedDestinationAirport = mockAirports.last(),
|
||||
selectedDate = LocalDate.now(),
|
||||
passengers = PassengersState(
|
||||
adultCount = 2,
|
||||
teenCount = 1,
|
||||
childCount = 1,
|
||||
),
|
||||
),
|
||||
uiState = homeUiState,
|
||||
searchResultsUiState = SearchResultUiState.Idle,
|
||||
onOriginAirportSelect = {},
|
||||
onDestinationAirportSelect = {},
|
||||
onDateSelect = {},
|
||||
onAdultCountChange = {},
|
||||
onTeenCountChange = {},
|
||||
onChildCountChange = {},
|
||||
onSearch = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewDevices
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
private fun HomeScreenSearchResultsPreview(
|
||||
@PreviewParameter(HomeUiStatePreviewParameterProvider::class) homeUiState: HomeUiState,
|
||||
) {
|
||||
val mockFlight = Flight(
|
||||
currency = "PLN",
|
||||
currPrecision = 2,
|
||||
trips = listOf<Trip>(
|
||||
Trip(
|
||||
origin = "WAW",
|
||||
destination = "KRK",
|
||||
dates = listOf<TripDate>(
|
||||
TripDate(
|
||||
dateOut = LocalDate(2023, 6, 15),
|
||||
flights = listOf(
|
||||
TripFlight(
|
||||
faresLeft = 10,
|
||||
regularFare = RegularFare(
|
||||
fares = listOf(
|
||||
TripFare(
|
||||
type = "ADT",
|
||||
amount = 150.0,
|
||||
count = 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
flightNumber = "FR1234",
|
||||
dateTimes = listOf(
|
||||
"2023-06-15T10:00:00",
|
||||
"2023-06-15T11:30:00",
|
||||
),
|
||||
duration = "1h 30m",
|
||||
segments = listOf(
|
||||
Segment(
|
||||
origin = "WAW",
|
||||
destination = "KRK",
|
||||
flightNumber = "FR1234",
|
||||
dateTimes = listOf(
|
||||
"2023-06-15T10:00:00",
|
||||
"2023-06-15T11:30:00",
|
||||
),
|
||||
duration = "1h 30m",
|
||||
),
|
||||
),
|
||||
operatedBy = "Ryanair",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
FlightsTheme {
|
||||
HomeScreen(
|
||||
uiState = homeUiState,
|
||||
searchResultsUiState = SearchResultUiState.Success(mockFlight),
|
||||
onOriginAirportSelect = {},
|
||||
onDestinationAirportSelect = {},
|
||||
onDateSelect = {},
|
||||
|
@ -10,6 +10,7 @@ import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase
|
||||
import dev.adriankuta.flights.domain.search.entities.SearchOptions
|
||||
import dev.adriankuta.flights.domain.types.Airport
|
||||
import dev.adriankuta.flights.domain.types.AirportInfo
|
||||
import dev.adriankuta.flights.domain.types.Flight
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@ -17,8 +18,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.time.LocalDate
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@ -29,10 +32,12 @@ class HomeScreenViewModel @Inject constructor(
|
||||
|
||||
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
|
||||
private val selectedDestinationAirport = MutableStateFlow<AirportInfo?>(null)
|
||||
private val selectedDate = MutableStateFlow(LocalDate.now())
|
||||
private val selectedDate =
|
||||
MutableStateFlow(Clock.System.todayIn(TimeZone.currentSystemDefault()))
|
||||
private val adultCount = MutableStateFlow(1)
|
||||
private val teenCount = MutableStateFlow(0)
|
||||
private val childCount = MutableStateFlow(0)
|
||||
private val searchResults = MutableStateFlow<SearchResultUiState>(SearchResultUiState.Idle)
|
||||
|
||||
internal val uiState = homeUiState(
|
||||
useCase = observeAirportsUseCase,
|
||||
@ -51,6 +56,13 @@ class HomeScreenViewModel @Inject constructor(
|
||||
initialValue = HomeUiState.Loading,
|
||||
)
|
||||
|
||||
internal val searchResultUiState = searchResults
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = SearchResultUiState.Idle,
|
||||
)
|
||||
|
||||
fun selectOriginAirport(airport: AirportInfo) {
|
||||
selectedOriginAirport.value = airport
|
||||
// If the selected destination is the same as the new origin, clear the destination
|
||||
@ -91,28 +103,40 @@ class HomeScreenViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun search() {
|
||||
searchResults.value = SearchResultUiState.Loading
|
||||
viewModelScope.launch {
|
||||
val results = getFlightsSearchContentUseCase(
|
||||
searchOptions = SearchOptions(
|
||||
origin = Airport.Departure(
|
||||
code = selectedOriginAirport.value?.code ?: return@launch,
|
||||
name = selectedOriginAirport.value?.name ?: return@launch,
|
||||
macCity = selectedOriginAirport.value?.macCity,
|
||||
),
|
||||
destination = Airport.Arrival(
|
||||
code = selectedDestinationAirport.value?.code ?: return@launch,
|
||||
name = selectedDestinationAirport.value?.name ?: return@launch,
|
||||
macCity = selectedDestinationAirport.value?.macCity,
|
||||
),
|
||||
date = selectedDate.value,
|
||||
adults = adultCount.value,
|
||||
teens = teenCount.value,
|
||||
children = childCount.value,
|
||||
),
|
||||
)
|
||||
Timber.d("Result $results")
|
||||
try {
|
||||
val results = getFlightsSearchContentUseCase(
|
||||
searchOptions = prepareSearchOptions(),
|
||||
)
|
||||
searchResults.value = SearchResultUiState.Success(results)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
searchResults.value = SearchResultUiState.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareSearchOptions(): SearchOptions {
|
||||
val selectedOrigin = requireNotNull(selectedOriginAirport.value)
|
||||
val selectedDestination = requireNotNull(selectedDestinationAirport.value)
|
||||
|
||||
return SearchOptions(
|
||||
origin = Airport.Departure(
|
||||
code = selectedOrigin.code,
|
||||
name = selectedOrigin.name,
|
||||
macCity = selectedOrigin.macCity,
|
||||
),
|
||||
destination = Airport.Arrival(
|
||||
code = selectedDestination.code,
|
||||
name = selectedDestination.name,
|
||||
macCity = selectedDestination.macCity,
|
||||
),
|
||||
date = selectedDate.value,
|
||||
adults = adultCount.value,
|
||||
teens = teenCount.value,
|
||||
children = childCount.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun homeUiState(
|
||||
@ -172,13 +196,20 @@ internal sealed interface HomeUiState {
|
||||
val destinationAirports: List<AirportInfo>,
|
||||
val selectedOriginAirport: AirportInfo? = null,
|
||||
val selectedDestinationAirport: AirportInfo? = null,
|
||||
val selectedDate: LocalDate = LocalDate.now(),
|
||||
val selectedDate: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||
val passengers: PassengersState,
|
||||
) : HomeUiState
|
||||
|
||||
data class Error(val exception: Throwable) : HomeUiState
|
||||
}
|
||||
|
||||
internal sealed interface SearchResultUiState {
|
||||
data object Loading : SearchResultUiState
|
||||
data class Success(val flight: Flight) : SearchResultUiState
|
||||
data class Error(val exception: Throwable) : SearchResultUiState
|
||||
data object Idle : SearchResultUiState
|
||||
}
|
||||
|
||||
internal data class PassengersState(
|
||||
val adultCount: Int,
|
||||
val teenCount: Int,
|
||||
|
@ -24,10 +24,16 @@ import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.datetime.todayIn
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -45,8 +51,8 @@ fun DatePicker(
|
||||
|
||||
// Ensure the selected date is not in the past
|
||||
val validatedDate = remember(selectedDate) {
|
||||
if (selectedDate.isBefore(LocalDate.now())) {
|
||||
LocalDate.now()
|
||||
if (selectedDate < Clock.System.todayIn(TimeZone.currentSystemDefault())) {
|
||||
Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
} else {
|
||||
selectedDate
|
||||
}
|
||||
@ -54,7 +60,7 @@ fun DatePicker(
|
||||
|
||||
// Format the date for display
|
||||
val formattedDate = remember(validatedDate) {
|
||||
validatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
validatedDate.toJavaLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
|
||||
}
|
||||
val interactionSource = remember {
|
||||
object : MutableInteractionSource {
|
||||
@ -93,9 +99,8 @@ fun DatePicker(
|
||||
if (showDatePicker) {
|
||||
val datePickerState = rememberDatePickerState(
|
||||
initialSelectedDateMillis = selectedDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli(),
|
||||
.atStartOfDayIn(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds(),
|
||||
selectableDates = FutureSelectableDates(),
|
||||
)
|
||||
|
||||
@ -109,11 +114,11 @@ fun DatePicker(
|
||||
TextButton(
|
||||
onClick = {
|
||||
datePickerState.selectedDateMillis?.let { millis ->
|
||||
val newDate = Instant.ofEpochMilli(millis)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
|
||||
val newDate = Instant.fromEpochMilliseconds(millis)
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
// Only allow present and future dates
|
||||
if (!newDate.isBefore(LocalDate.now())) {
|
||||
if (newDate >= Clock.System.todayIn(TimeZone.currentSystemDefault())) {
|
||||
onDateSelect(newDate)
|
||||
}
|
||||
}
|
||||
@ -141,8 +146,8 @@ fun DatePicker(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class FutureSelectableDates : SelectableDates {
|
||||
private val now = LocalDate.now()
|
||||
private val dayStart = now.atTime(0, 0, 0, 0).toEpochSecond(ZoneOffset.UTC) * 1000
|
||||
private val now = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
private val dayStart = now.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds()
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
|
||||
@ -159,8 +164,8 @@ class FutureSelectableDates : SelectableDates {
|
||||
@PreviewDevices
|
||||
@Composable
|
||||
private fun DatePickerPreview() {
|
||||
val today = LocalDate.now()
|
||||
val futureDate = today.plusDays(7) // A week from today
|
||||
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
val futureDate = today.plus(7, DateTimeUnit.DAY)
|
||||
|
||||
FlightsTheme {
|
||||
DatePicker(
|
||||
|
@ -14,6 +14,9 @@ import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||
import dev.adriankuta.flights.ui.home.HomeUiState
|
||||
import dev.adriankuta.flights.ui.home.PassengersState
|
||||
import dev.adriankuta.flights.ui.sharedui.Counter
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
@Composable
|
||||
internal fun PassengersOptions(
|
||||
@ -71,7 +74,7 @@ private fun PassengersOptionsPreview() {
|
||||
destinationAirports = emptyList(),
|
||||
selectedOriginAirport = null,
|
||||
selectedDestinationAirport = null,
|
||||
selectedDate = java.time.LocalDate.now(),
|
||||
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||
passengers = samplePassengersState,
|
||||
)
|
||||
|
||||
|
@ -19,7 +19,12 @@ import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
|
||||
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
|
||||
import dev.adriankuta.flights.ui.home.HomeUiState
|
||||
import dev.adriankuta.flights.ui.home.PassengersState
|
||||
import java.time.LocalDate
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
@Composable
|
||||
internal fun SearchForm(
|
||||
@ -155,7 +160,8 @@ private fun SearchFormPreview() {
|
||||
destinationAirports = sampleAirports.drop(1),
|
||||
selectedOriginAirport = sampleAirports.first(),
|
||||
selectedDestinationAirport = sampleAirports.last(),
|
||||
selectedDate = LocalDate.now().plusDays(7),
|
||||
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
|
||||
.plus(7, DateTimeUnit.DAY),
|
||||
passengers = samplePassengersState,
|
||||
)
|
||||
|
||||
|
@ -0,0 +1,170 @@
|
||||
package dev.adriankuta.flights.ui.home.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.adriankuta.flights.domain.types.Flight
|
||||
import dev.adriankuta.flights.domain.types.Trip
|
||||
import dev.adriankuta.flights.domain.types.TripDate
|
||||
import dev.adriankuta.flights.domain.types.TripFlight
|
||||
import dev.adriankuta.flights.ui.home.SearchResultUiState
|
||||
import dev.adriankuta.flights.ui.home.sample.FlightPreviewParameterProvider
|
||||
|
||||
@Composable
|
||||
internal fun SearchResults(
|
||||
uiState: SearchResultUiState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
stickyHeader {
|
||||
Text(
|
||||
text = "Search Results",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (uiState) {
|
||||
is SearchResultUiState.Error -> item { Text("Error") }
|
||||
SearchResultUiState.Idle -> Unit
|
||||
SearchResultUiState.Loading -> item { Text("Loading") }
|
||||
is SearchResultUiState.Success -> items(uiState.flight.trips) { trip ->
|
||||
TripCard(trip = trip, currency = uiState.flight.currency)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripCard(
|
||||
trip: Trip,
|
||||
currency: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "${trip.origin} → ${trip.destination}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Column {
|
||||
trip.dates.forEach { tripDate ->
|
||||
TripDateItem(tripDate = tripDate, currency = currency)
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TripDateItem(
|
||||
tripDate: TripDate,
|
||||
currency: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Date: ${tripDate.dateOut}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
tripDate.flights.forEach { flight ->
|
||||
FlightItem(flight = flight, currency = currency)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FlightItem(
|
||||
flight: TripFlight,
|
||||
currency: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Flight: ${flight.flightNumber}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "Duration: ${flight.duration}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "Operated by: ${flight.operatedBy}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Display price information
|
||||
val totalPrice = flight.regularFare.fares.sumOf { it.amount * it.count }
|
||||
Text(
|
||||
text = "Price: $totalPrice $currency",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Seats available: ${flight.faresLeft}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SearchResultsPreview(@PreviewParameter(FlightPreviewParameterProvider::class) flight: Flight) {
|
||||
SearchResults(uiState = SearchResultUiState.Success(flight))
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
package dev.adriankuta.flights.ui.home.sample
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import dev.adriankuta.flights.domain.types.Flight
|
||||
import dev.adriankuta.flights.domain.types.RegularFare
|
||||
import dev.adriankuta.flights.domain.types.Segment
|
||||
import dev.adriankuta.flights.domain.types.Trip
|
||||
import dev.adriankuta.flights.domain.types.TripDate
|
||||
import dev.adriankuta.flights.domain.types.TripFare
|
||||
import dev.adriankuta.flights.domain.types.TripFlight
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
internal class FlightPreviewParameterProvider : PreviewParameterProvider<Flight> {
|
||||
override val values: Sequence<Flight> = sequenceOf(
|
||||
Flight(
|
||||
currency = "EUR",
|
||||
currPrecision = 2,
|
||||
trips = listOf(
|
||||
Trip(
|
||||
origin = "WAW",
|
||||
destination = "LDN",
|
||||
dates = listOf(
|
||||
TripDate(
|
||||
dateOut = LocalDate(2023, 6, 15),
|
||||
flights = listOf(
|
||||
TripFlight(
|
||||
faresLeft = 5,
|
||||
regularFare = RegularFare(
|
||||
fares = listOf(
|
||||
TripFare(
|
||||
type = "ADULT",
|
||||
amount = 99.99,
|
||||
count = 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
flightNumber = "FR1234",
|
||||
dateTimes = listOf(
|
||||
"2023-06-15T10:00:00",
|
||||
"2023-06-15T12:30:00",
|
||||
),
|
||||
duration = "2h 30m",
|
||||
segments = listOf(
|
||||
Segment(
|
||||
origin = "WAW",
|
||||
destination = "LDN",
|
||||
flightNumber = "FR1234",
|
||||
dateTimes = listOf(
|
||||
"2023-06-15T10:00:00",
|
||||
"2023-06-15T12:30:00",
|
||||
),
|
||||
duration = "2h 30m",
|
||||
),
|
||||
),
|
||||
operatedBy = "Ryanair",
|
||||
),
|
||||
),
|
||||
),
|
||||
TripDate(
|
||||
dateOut = LocalDate(2023, 6, 16),
|
||||
flights = listOf(
|
||||
TripFlight(
|
||||
faresLeft = 3,
|
||||
regularFare = RegularFare(
|
||||
fares = listOf(
|
||||
TripFare(
|
||||
type = "ADULT",
|
||||
amount = 129.99,
|
||||
count = 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
flightNumber = "FR5678",
|
||||
dateTimes = listOf(
|
||||
"2023-06-16T14:00:00",
|
||||
"2023-06-16T16:15:00",
|
||||
),
|
||||
duration = "2h 15m",
|
||||
segments = listOf(
|
||||
Segment(
|
||||
origin = "WAW",
|
||||
destination = "LDN",
|
||||
flightNumber = "FR5678",
|
||||
dateTimes = listOf(
|
||||
"2023-06-16T14:00:00",
|
||||
"2023-06-16T16:15:00",
|
||||
),
|
||||
duration = "2h 15m",
|
||||
),
|
||||
),
|
||||
operatedBy = "Ryanair",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Trip(
|
||||
origin = "LDN",
|
||||
destination = "WAW",
|
||||
dates = listOf(
|
||||
TripDate(
|
||||
dateOut = LocalDate(2023, 6, 22),
|
||||
flights = listOf(
|
||||
TripFlight(
|
||||
faresLeft = 10,
|
||||
regularFare = RegularFare(
|
||||
fares = listOf(
|
||||
TripFare(
|
||||
type = "ADULT",
|
||||
amount = 89.99,
|
||||
count = 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
flightNumber = "FR9876",
|
||||
dateTimes = listOf(
|
||||
"2023-06-22T08:30:00",
|
||||
"2023-06-22T10:45:00",
|
||||
),
|
||||
duration = "2h 15m",
|
||||
segments = listOf(
|
||||
Segment(
|
||||
origin = "LDN",
|
||||
destination = "WAW",
|
||||
flightNumber = "FR9876",
|
||||
dateTimes = listOf(
|
||||
"2023-06-22T08:30:00",
|
||||
"2023-06-22T10:45:00",
|
||||
),
|
||||
duration = "2h 15m",
|
||||
),
|
||||
),
|
||||
operatedBy = "Ryanair",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package dev.adriankuta.flights.ui.home.sample
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import dev.adriankuta.flights.domain.types.AirportInfo
|
||||
import dev.adriankuta.flights.domain.types.City
|
||||
import dev.adriankuta.flights.domain.types.Coordinates
|
||||
import dev.adriankuta.flights.domain.types.Country
|
||||
import dev.adriankuta.flights.domain.types.MacCity
|
||||
import dev.adriankuta.flights.domain.types.Region
|
||||
import dev.adriankuta.flights.ui.home.HomeUiState
|
||||
import dev.adriankuta.flights.ui.home.PassengersState
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.todayIn
|
||||
|
||||
internal class HomeUiStatePreviewParameterProvider : PreviewParameterProvider<HomeUiState> {
|
||||
|
||||
private val mockAirports = listOf(
|
||||
AirportInfo(
|
||||
code = "WAW",
|
||||
name = "Warsaw Chopin Airport",
|
||||
seoName = "warsaw",
|
||||
isBase = true,
|
||||
timeZone = "Europe/Warsaw",
|
||||
city = City("WAW", "Warsaw"),
|
||||
macCity = MacCity("WARSAW"),
|
||||
region = Region("WARSAW_PL", "Warsaw"),
|
||||
country = Country("PL", "Poland", "PLN"),
|
||||
coordinates = Coordinates(52.1657, 20.9671),
|
||||
),
|
||||
AirportInfo(
|
||||
code = "KRK",
|
||||
name = "Krakow Airport",
|
||||
seoName = "krakow",
|
||||
isBase = true,
|
||||
timeZone = "Europe/Warsaw",
|
||||
city = City("KRK", "Krakow"),
|
||||
macCity = MacCity("KRAKOW"),
|
||||
region = Region("KRAKOW_PL", "Krakow"),
|
||||
country = Country("PL", "Poland", "PLN"),
|
||||
coordinates = Coordinates(50.0777, 19.7848),
|
||||
),
|
||||
)
|
||||
|
||||
override val values: Sequence<HomeUiState> = sequenceOf(
|
||||
HomeUiState.Success(
|
||||
originAirports = mockAirports,
|
||||
destinationAirports = mockAirports,
|
||||
selectedOriginAirport = mockAirports.first(),
|
||||
selectedDestinationAirport = mockAirports.last(),
|
||||
selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
|
||||
passengers = PassengersState(
|
||||
adultCount = 1,
|
||||
teenCount = 0,
|
||||
childCount = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user