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:
2025-06-15 21:30:51 +02:00
parent ffcfc1f45b
commit 762c6338de
16 changed files with 591 additions and 115 deletions

View File

@ -61,6 +61,7 @@ internal fun Project.configureKotlinAndroid(
dependencies { dependencies {
"implementation"(libs.findLibrary("androidx.core.ktx").get()) "implementation"(libs.findLibrary("androidx.core.ktx").get())
"implementation"(libs.findLibrary("kotlinx.coroutines.android").get()) "implementation"(libs.findLibrary("kotlinx.coroutines.android").get())
"implementation"(libs.findLibrary("kotlinx.datetime").get())
"implementation"(libs.findLibrary("timber").get()) "implementation"(libs.findLibrary("timber").get())
"coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get()) "coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())

View File

@ -9,5 +9,4 @@ android {
dependencies { dependencies {
api(projects.domain.types) api(projects.domain.types)
implementation(libs.timber) }
}

View File

@ -1,7 +1,7 @@
package dev.adriankuta.flights.domain.search.entities package dev.adriankuta.flights.domain.search.entities
import dev.adriankuta.flights.domain.types.Airport import dev.adriankuta.flights.domain.types.Airport
import java.time.LocalDate import kotlinx.datetime.LocalDate
data class SearchOptions( data class SearchOptions(
val origin: Airport.Departure, val origin: Airport.Departure,

View File

@ -9,5 +9,4 @@ android {
dependencies { dependencies {
implementation(projects.core.util) implementation(projects.core.util)
implementation(libs.timber) }
}

View File

@ -1,6 +1,8 @@
package dev.adriankuta.flights.domain.types package dev.adriankuta.flights.domain.types
import kotlinx.datetime.LocalDate
data class TripDate( data class TripDate(
val dateOut: String, val dateOut: LocalDate?,
val flights: List<TripFlight>, val flights: List<TripFlight>,
) )

View File

@ -8,6 +8,7 @@ import dev.adriankuta.flights.domain.types.TripDate
import dev.adriankuta.flights.domain.types.TripFare import dev.adriankuta.flights.domain.types.TripFare
import dev.adriankuta.flights.domain.types.TripFlight import dev.adriankuta.flights.domain.types.TripFlight
import dev.adriankuta.model.data.api.entities.FlightResponse 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.RegularFare as ApiRegularFare
import dev.adriankuta.model.data.api.entities.Segment as ApiSegment 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.Trip as ApiTrip
@ -33,7 +34,7 @@ internal fun ApiTrip.toDomain(): Trip {
internal fun ApiTripDate.toDomain(): TripDate { internal fun ApiTripDate.toDomain(): TripDate {
return TripDate( return TripDate(
dateOut = dateOut ?: "", dateOut = dateOut?.let { LocalDateTime.parse(it).date },
flights = flights.orEmpty().map { it.toDomain() }, flights = flights.orEmpty().map { it.toDomain() },
) )
} }

View File

@ -5,7 +5,6 @@ import dev.adriankuta.flights.domain.search.entities.SearchOptions
import dev.adriankuta.flights.domain.types.Flight import dev.adriankuta.flights.domain.types.Flight
import dev.adriankuta.flights.model.repository.mappers.toDomain import dev.adriankuta.flights.model.repository.mappers.toDomain
import dev.adriankuta.model.data.api.FlightService import dev.adriankuta.model.data.api.FlightService
import java.time.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject
internal class GetFlightsSearchContentUseCaseImpl @Inject constructor( internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
@ -16,7 +15,7 @@ internal class GetFlightsSearchContentUseCaseImpl @Inject constructor(
val result = flightService.getFlights( val result = flightService.getFlights(
origin = searchOptions.origin.code, origin = searchOptions.origin.code,
destination = searchOptions.destination.code, destination = searchOptions.destination.code,
date = searchOptions.date.format(DateTimeFormatter.ISO_DATE), date = searchOptions.date.toString(),
adult = searchOptions.adults, adult = searchOptions.adults,
teen = searchOptions.teens, teen = searchOptions.teens,
child = searchOptions.children, child = searchOptions.children,

View File

@ -15,5 +15,4 @@ dependencies {
implementation(projects.domain.search) implementation(projects.domain.search)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.timber)
} }

View File

@ -1,5 +1,6 @@
package dev.adriankuta.flights.ui.home package dev.adriankuta.flights.ui.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -7,29 +8,39 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.Flight
import dev.adriankuta.flights.domain.types.Coordinates import dev.adriankuta.flights.domain.types.RegularFare
import dev.adriankuta.flights.domain.types.Country import dev.adriankuta.flights.domain.types.Segment
import dev.adriankuta.flights.domain.types.MacCity import dev.adriankuta.flights.domain.types.Trip
import dev.adriankuta.flights.domain.types.Region 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.FlightsTheme
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
import dev.adriankuta.flights.ui.home.components.SearchForm 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 @Composable
internal fun HomeScreen( internal fun HomeScreen(
viewModel: HomeScreenViewModel = hiltViewModel(), viewModel: HomeScreenViewModel = hiltViewModel(),
) { ) {
val homeUiState by viewModel.uiState.collectAsStateWithLifecycle() val homeUiState by viewModel.uiState.collectAsStateWithLifecycle()
val searchResults by viewModel.searchResultUiState.collectAsStateWithLifecycle()
HomeScreen( HomeScreen(
uiState = homeUiState, uiState = homeUiState,
searchResultsUiState = searchResults,
onOriginAirportSelect = viewModel::selectOriginAirport, onOriginAirportSelect = viewModel::selectOriginAirport,
onDestinationAirportSelect = viewModel::selectDestinationAirport, onDestinationAirportSelect = viewModel::selectDestinationAirport,
onDateSelect = viewModel::selectDate, onDateSelect = viewModel::selectDate,
@ -43,6 +54,7 @@ internal fun HomeScreen(
@Composable @Composable
private fun HomeScreen( private fun HomeScreen(
uiState: HomeUiState, uiState: HomeUiState,
searchResultsUiState: SearchResultUiState,
onOriginAirportSelect: (AirportInfo) -> Unit, onOriginAirportSelect: (AirportInfo) -> Unit,
onDestinationAirportSelect: (AirportInfo) -> Unit, onDestinationAirportSelect: (AirportInfo) -> Unit,
onDateSelect: (LocalDate) -> Unit, onDateSelect: (LocalDate) -> Unit,
@ -52,27 +64,39 @@ private fun HomeScreen(
onSearch: () -> Unit, onSearch: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scrollState = rememberScrollState() var showSearchResults by rememberSaveable { mutableStateOf(false) }
BackHandler(enabled = showSearchResults) {
showSearchResults = false
}
Box( Box(
modifier = modifier modifier = modifier
.padding(16.dp) .padding(16.dp),
.verticalScroll(
state = scrollState,
),
) { ) {
when (uiState) { when (uiState) {
is HomeUiState.Error -> Text("Error") is HomeUiState.Error -> Text("Error")
HomeUiState.Loading -> Text("Loading") HomeUiState.Loading -> Text("Loading")
is HomeUiState.Success -> SearchForm( is HomeUiState.Success -> if (showSearchResults) {
uiState = uiState, SearchResults(
onOriginAirportSelect = onOriginAirportSelect, uiState = searchResultsUiState,
onDestinationAirportSelect = onDestinationAirportSelect, )
onDateSelect = onDateSelect, } else {
onAdultCountChange = onAdultCountChange, SearchForm(
onTeenCountChange = onTeenCountChange, uiState = uiState,
onChildCountChange = onChildCountChange, onOriginAirportSelect = onOriginAirportSelect,
onSearch = onSearch, 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 { FlightsTheme {
HomeScreen( HomeScreen(
uiState = HomeUiState.Loading, uiState = HomeUiState.Loading,
searchResultsUiState = SearchResultUiState.Idle,
onOriginAirportSelect = {}, onOriginAirportSelect = {},
onDestinationAirportSelect = {}, onDestinationAirportSelect = {},
onDateSelect = {}, onDateSelect = {},
@ -96,48 +121,83 @@ private fun HomeScreenLoadingPreview() {
@PreviewDevices @PreviewDevices
@Composable @Composable
private fun HomeScreenSuccessPreview() { private fun HomeScreenSuccessPreview(
@PreviewParameter(HomeUiStatePreviewParameterProvider::class) homeUiState: HomeUiState,
) {
FlightsTheme { 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( HomeScreen(
uiState = HomeUiState.Success( uiState = homeUiState,
originAirports = mockAirports, searchResultsUiState = SearchResultUiState.Idle,
destinationAirports = mockAirports, onOriginAirportSelect = {},
selectedOriginAirport = mockAirports.first(), onDestinationAirportSelect = {},
selectedDestinationAirport = mockAirports.last(), onDateSelect = {},
selectedDate = LocalDate.now(), onAdultCountChange = {},
passengers = PassengersState( onTeenCountChange = {},
adultCount = 2, onChildCountChange = {},
teenCount = 1, onSearch = {},
childCount = 1, )
), }
), }
@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 = {}, onOriginAirportSelect = {},
onDestinationAirportSelect = {}, onDestinationAirportSelect = {},
onDateSelect = {}, onDateSelect = {},

View File

@ -10,6 +10,7 @@ import dev.adriankuta.flights.domain.search.ObserveAirportsUseCase
import dev.adriankuta.flights.domain.search.entities.SearchOptions import dev.adriankuta.flights.domain.search.entities.SearchOptions
import dev.adriankuta.flights.domain.types.Airport import dev.adriankuta.flights.domain.types.Airport
import dev.adriankuta.flights.domain.types.AirportInfo import dev.adriankuta.flights.domain.types.AirportInfo
import dev.adriankuta.flights.domain.types.Flight
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -17,8 +18,10 @@ 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 kotlinx.coroutines.launch
import timber.log.Timber import kotlinx.datetime.Clock
import java.time.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -29,10 +32,12 @@ class HomeScreenViewModel @Inject constructor(
private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null) private val selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
private val selectedDestinationAirport = 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 adultCount = MutableStateFlow(1)
private val teenCount = MutableStateFlow(0) private val teenCount = MutableStateFlow(0)
private val childCount = MutableStateFlow(0) private val childCount = MutableStateFlow(0)
private val searchResults = MutableStateFlow<SearchResultUiState>(SearchResultUiState.Idle)
internal val uiState = homeUiState( internal val uiState = homeUiState(
useCase = observeAirportsUseCase, useCase = observeAirportsUseCase,
@ -51,6 +56,13 @@ class HomeScreenViewModel @Inject constructor(
initialValue = HomeUiState.Loading, initialValue = HomeUiState.Loading,
) )
internal val searchResultUiState = searchResults
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchResultUiState.Idle,
)
fun selectOriginAirport(airport: AirportInfo) { fun selectOriginAirport(airport: AirportInfo) {
selectedOriginAirport.value = airport selectedOriginAirport.value = airport
// If the selected destination is the same as the new origin, clear the destination // If the selected destination is the same as the new origin, clear the destination
@ -91,28 +103,40 @@ class HomeScreenViewModel @Inject constructor(
} }
fun search() { fun search() {
searchResults.value = SearchResultUiState.Loading
viewModelScope.launch { viewModelScope.launch {
val results = getFlightsSearchContentUseCase( try {
searchOptions = SearchOptions( val results = getFlightsSearchContentUseCase(
origin = Airport.Departure( searchOptions = prepareSearchOptions(),
code = selectedOriginAirport.value?.code ?: return@launch, )
name = selectedOriginAirport.value?.name ?: return@launch, searchResults.value = SearchResultUiState.Success(results)
macCity = selectedOriginAirport.value?.macCity, } catch (e: IllegalArgumentException) {
), searchResults.value = SearchResultUiState.Error(e)
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")
} }
} }
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( private fun homeUiState(
@ -172,13 +196,20 @@ internal sealed interface HomeUiState {
val destinationAirports: List<AirportInfo>, val destinationAirports: List<AirportInfo>,
val selectedOriginAirport: AirportInfo? = null, val selectedOriginAirport: AirportInfo? = null,
val selectedDestinationAirport: AirportInfo? = null, val selectedDestinationAirport: AirportInfo? = null,
val selectedDate: LocalDate = LocalDate.now(), val selectedDate: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
val passengers: PassengersState, val passengers: PassengersState,
) : HomeUiState ) : HomeUiState
data class Error(val exception: Throwable) : 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( internal data class PassengersState(
val adultCount: Int, val adultCount: Int,
val teenCount: Int, val teenCount: Int,

View File

@ -24,10 +24,16 @@ 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 kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import java.time.Instant import kotlinx.datetime.Clock
import java.time.LocalDate import kotlinx.datetime.DateTimeUnit
import java.time.ZoneId import kotlinx.datetime.Instant
import java.time.ZoneOffset 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 import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -45,8 +51,8 @@ fun DatePicker(
// Ensure the selected date is not in the past // Ensure the selected date is not in the past
val validatedDate = remember(selectedDate) { val validatedDate = remember(selectedDate) {
if (selectedDate.isBefore(LocalDate.now())) { if (selectedDate < Clock.System.todayIn(TimeZone.currentSystemDefault())) {
LocalDate.now() Clock.System.todayIn(TimeZone.currentSystemDefault())
} else { } else {
selectedDate selectedDate
} }
@ -54,7 +60,7 @@ fun DatePicker(
// Format the date for display // Format the date for display
val formattedDate = remember(validatedDate) { val formattedDate = remember(validatedDate) {
validatedDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) validatedDate.toJavaLocalDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
} }
val interactionSource = remember { val interactionSource = remember {
object : MutableInteractionSource { object : MutableInteractionSource {
@ -93,9 +99,8 @@ fun DatePicker(
if (showDatePicker) { if (showDatePicker) {
val datePickerState = rememberDatePickerState( val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = selectedDate initialSelectedDateMillis = selectedDate
.atStartOfDay(ZoneId.systemDefault()) .atStartOfDayIn(TimeZone.currentSystemDefault())
.toInstant() .toEpochMilliseconds(),
.toEpochMilli(),
selectableDates = FutureSelectableDates(), selectableDates = FutureSelectableDates(),
) )
@ -109,11 +114,11 @@ fun DatePicker(
TextButton( TextButton(
onClick = { onClick = {
datePickerState.selectedDateMillis?.let { millis -> datePickerState.selectedDateMillis?.let { millis ->
val newDate = Instant.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault()) val newDate = Instant.fromEpochMilliseconds(millis)
.toLocalDate() .toLocalDateTime(TimeZone.currentSystemDefault()).date
// Only allow present and future dates // Only allow present and future dates
if (!newDate.isBefore(LocalDate.now())) { if (newDate >= Clock.System.todayIn(TimeZone.currentSystemDefault())) {
onDateSelect(newDate) onDateSelect(newDate)
} }
} }
@ -141,8 +146,8 @@ fun DatePicker(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
class FutureSelectableDates : SelectableDates { class FutureSelectableDates : SelectableDates {
private val now = LocalDate.now() private val now = Clock.System.todayIn(TimeZone.currentSystemDefault())
private val dayStart = now.atTime(0, 0, 0, 0).toEpochSecond(ZoneOffset.UTC) * 1000 private val dayStart = now.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds()
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
override fun isSelectableDate(utcTimeMillis: Long): Boolean { override fun isSelectableDate(utcTimeMillis: Long): Boolean {
@ -159,8 +164,8 @@ class FutureSelectableDates : SelectableDates {
@PreviewDevices @PreviewDevices
@Composable @Composable
private fun DatePickerPreview() { private fun DatePickerPreview() {
val today = LocalDate.now() val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
val futureDate = today.plusDays(7) // A week from today val futureDate = today.plus(7, DateTimeUnit.DAY)
FlightsTheme { FlightsTheme {
DatePicker( DatePicker(

View File

@ -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.HomeUiState
import dev.adriankuta.flights.ui.home.PassengersState import dev.adriankuta.flights.ui.home.PassengersState
import dev.adriankuta.flights.ui.sharedui.Counter import dev.adriankuta.flights.ui.sharedui.Counter
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
@Composable @Composable
internal fun PassengersOptions( internal fun PassengersOptions(
@ -71,7 +74,7 @@ private fun PassengersOptionsPreview() {
destinationAirports = emptyList(), destinationAirports = emptyList(),
selectedOriginAirport = null, selectedOriginAirport = null,
selectedDestinationAirport = null, selectedDestinationAirport = null,
selectedDate = java.time.LocalDate.now(), selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault()),
passengers = samplePassengersState, passengers = samplePassengersState,
) )

View File

@ -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.designsystem.theme.PreviewDevices
import dev.adriankuta.flights.ui.home.HomeUiState import dev.adriankuta.flights.ui.home.HomeUiState
import dev.adriankuta.flights.ui.home.PassengersState 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 @Composable
internal fun SearchForm( internal fun SearchForm(
@ -155,7 +160,8 @@ private fun SearchFormPreview() {
destinationAirports = sampleAirports.drop(1), destinationAirports = sampleAirports.drop(1),
selectedOriginAirport = sampleAirports.first(), selectedOriginAirport = sampleAirports.first(),
selectedDestinationAirport = sampleAirports.last(), selectedDestinationAirport = sampleAirports.last(),
selectedDate = LocalDate.now().plusDays(7), selectedDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
.plus(7, DateTimeUnit.DAY),
passengers = samplePassengersState, passengers = samplePassengersState,
) )

View File

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

View File

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

View File

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