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 {
"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())

View File

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

View File

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

View File

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

View File

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

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.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() },
)
}

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

View File

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

View File

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

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

View File

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

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

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

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