diff --git a/build-logic/convention/src/main/kotlin/dev/adriankuta/partymania/ConfigureKotlinAndroid.kt b/build-logic/convention/src/main/kotlin/dev/adriankuta/partymania/ConfigureKotlinAndroid.kt index 97902d1..bfd4504 100644 --- a/build-logic/convention/src/main/kotlin/dev/adriankuta/partymania/ConfigureKotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/dev/adriankuta/partymania/ConfigureKotlinAndroid.kt @@ -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()) diff --git a/domain/search/build.gradle.kts b/domain/search/build.gradle.kts index ec13401..f5b9f20 100644 --- a/domain/search/build.gradle.kts +++ b/domain/search/build.gradle.kts @@ -9,5 +9,4 @@ android { dependencies { api(projects.domain.types) - implementation(libs.timber) -} \ No newline at end of file +} diff --git a/domain/search/src/main/kotlin/dev/adriankuta/flights/domain/search/entities/SearchOptions.kt b/domain/search/src/main/kotlin/dev/adriankuta/flights/domain/search/entities/SearchOptions.kt index 376c4a1..bcb3b03 100644 --- a/domain/search/src/main/kotlin/dev/adriankuta/flights/domain/search/entities/SearchOptions.kt +++ b/domain/search/src/main/kotlin/dev/adriankuta/flights/domain/search/entities/SearchOptions.kt @@ -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, diff --git a/domain/types/build.gradle.kts b/domain/types/build.gradle.kts index 39262e7..d2f31d7 100644 --- a/domain/types/build.gradle.kts +++ b/domain/types/build.gradle.kts @@ -9,5 +9,4 @@ android { dependencies { implementation(projects.core.util) - implementation(libs.timber) -} \ No newline at end of file +} diff --git a/domain/types/src/main/kotlin/dev/adriankuta/flights/domain/types/TripDate.kt b/domain/types/src/main/kotlin/dev/adriankuta/flights/domain/types/TripDate.kt index 56935d1..c9a1954 100644 --- a/domain/types/src/main/kotlin/dev/adriankuta/flights/domain/types/TripDate.kt +++ b/domain/types/src/main/kotlin/dev/adriankuta/flights/domain/types/TripDate.kt @@ -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, ) diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/mappers/FlightDomainMapper.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/mappers/FlightDomainMapper.kt index 1e691de..081b6f5 100644 --- a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/mappers/FlightDomainMapper.kt +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/mappers/FlightDomainMapper.kt @@ -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() }, ) } diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImpl.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImpl.kt index 2014598..a92df51 100644 --- a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImpl.kt +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetFlightsSearchContentUseCaseImpl.kt @@ -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, diff --git a/ui/home/build.gradle.kts b/ui/home/build.gradle.kts index bf07e04..87fc91d 100644 --- a/ui/home/build.gradle.kts +++ b/ui/home/build.gradle.kts @@ -15,5 +15,4 @@ dependencies { implementation(projects.domain.search) implementation(libs.androidx.hilt.navigation.compose) - implementation(libs.timber) } diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt index c975846..02c11a5 100644 --- a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreen.kt @@ -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( + origin = "WAW", + destination = "KRK", + dates = listOf( + 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 = {}, diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt index 162b76f..2247c6c 100644 --- a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/HomeScreenViewModel.kt @@ -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(null) private val selectedDestinationAirport = MutableStateFlow(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.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, 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, diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt index f4d8dae..ca1625a 100644 --- a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/DatePicker.kt @@ -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( diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/PassengersOptions.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/PassengersOptions.kt index a13023c..5d52d34 100644 --- a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/PassengersOptions.kt +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/PassengersOptions.kt @@ -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, ) diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchForm.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchForm.kt index 1e9d4b9..44cbc59 100644 --- a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchForm.kt +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchForm.kt @@ -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, ) diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchResults.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchResults.kt new file mode 100644 index 0000000..3f35f4d --- /dev/null +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/components/SearchResults.kt @@ -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)) +} diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/sample/FlightPreviewParameterProvider.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/sample/FlightPreviewParameterProvider.kt new file mode 100644 index 0000000..90140f8 --- /dev/null +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/sample/FlightPreviewParameterProvider.kt @@ -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 { + override val values: Sequence = 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", + ), + ), + ), + ), + ), + ), + ), + ) +} diff --git a/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/sample/HomeUiStatePreviewParameterProvider.kt b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/sample/HomeUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000..4d92d44 --- /dev/null +++ b/ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/sample/HomeUiStatePreviewParameterProvider.kt @@ -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 { + + 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 = 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, + ), + ), + ) +}