feat: Add passenger counter to home screen

This commit introduces a passenger counter component to the home screen, allowing users to specify the number of adults, teens, and children for their flight search.

Key changes:
- Added `ui:sharedui` module dependency to `ui:home`.
- Updated `PreviewDevices` annotation to include `showBackground = true`.
- Modified `HomeScreen`:
    - Added parameters to `HomeContent` and `SearchForm` for passenger count change callbacks.
    - Integrated the `Counter` composable from `ui:sharedui` for adults, teens, and children.
    - Added `VerticalDivider` between counters.
    - Updated previews to reflect passenger counter integration.
- Updated `HomeScreenViewModel`:
    - Introduced `MutableStateFlow` for `adultCount`, `teenCount`, and `childCount`.
    - Created `passengersState` flow by combining individual passenger count flows.
    - Updated `homeUiState` to include `passengersState`.
    - Added public functions `updateAdultCount`, `updateTeenCount`, and `updateChildCount` to modify passenger counts with validation.
    - Added `PassengersState` data class to hold passenger counts.
- Updated `Counter` composable in `ui:sharedui`:
    - Added an optional `minVal` parameter (defaulting to 0) to define the minimum allowed value for the counter.
    - Ensured the decrement button is disabled when the counter value reaches `minVal`.
    - Centered text in increment and decrement buttons.
This commit is contained in:
2025-06-14 09:40:59 +02:00
parent 6f6b886418
commit b23baa587c
5 changed files with 177 additions and 40 deletions

View File

@ -2,8 +2,8 @@ package dev.adriankuta.flights.ui.designsystem.theme
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480") @Preview(name = "phone", device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480", showBackground = true)
@Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480") @Preview(name = "landscape", device = "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480", showBackground = true)
@Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480") @Preview(name = "foldable", device = "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480", showBackground = true)
@Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480") @Preview(name = "tablet", device = "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480", showBackground = true)
annotation class PreviewDevices annotation class PreviewDevices

View File

@ -11,8 +11,9 @@ android {
dependencies { dependencies {
implementation(projects.core.util) implementation(projects.core.util)
implementation(projects.ui.designsystem) implementation(projects.ui.designsystem)
implementation(projects.ui.sharedui)
implementation(projects.domain.search) implementation(projects.domain.search)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.timber) implementation(libs.timber)
} }

View File

@ -1,11 +1,16 @@
package dev.adriankuta.flights.ui.home package dev.adriankuta.flights.ui.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -22,6 +27,7 @@ import dev.adriankuta.flights.ui.designsystem.theme.FlightsTheme
import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices import dev.adriankuta.flights.ui.designsystem.theme.PreviewDevices
import dev.adriankuta.flights.ui.home.components.AirportDropdown import dev.adriankuta.flights.ui.home.components.AirportDropdown
import dev.adriankuta.flights.ui.home.components.DatePicker import dev.adriankuta.flights.ui.home.components.DatePicker
import dev.adriankuta.flights.ui.sharedui.Counter
import java.time.LocalDate import java.time.LocalDate
@Composable @Composable
@ -35,6 +41,9 @@ internal fun HomeScreen(
onOriginAirportSelect = viewModel::selectOriginAirport, onOriginAirportSelect = viewModel::selectOriginAirport,
onDestinationAirportSelect = viewModel::selectDestinationAirport, onDestinationAirportSelect = viewModel::selectDestinationAirport,
onDateSelect = viewModel::selectDate, onDateSelect = viewModel::selectDate,
onAdultCountChange = viewModel::updateAdultCount,
onTeenCountChange = viewModel::updateTeenCount,
onChildCountChange = viewModel::updateChildCount,
) )
} }
@ -44,6 +53,9 @@ private fun HomeScreen(
onOriginAirportSelect: (AirportInfo) -> Unit, onOriginAirportSelect: (AirportInfo) -> Unit,
onDestinationAirportSelect: (AirportInfo) -> Unit, onDestinationAirportSelect: (AirportInfo) -> Unit,
onDateSelect: (LocalDate) -> Unit, onDateSelect: (LocalDate) -> Unit,
onAdultCountChange: (Int) -> Unit,
onTeenCountChange: (Int) -> Unit,
onChildCountChange: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( Column(
@ -52,40 +64,98 @@ private fun HomeScreen(
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 -> { is HomeUiState.Success -> SearchForm(
AirportDropdown( uiState = uiState,
label = "Origin Airport", onOriginAirportSelect = onOriginAirportSelect,
airports = uiState.originAirports, onDestinationAirportSelect = onDestinationAirportSelect,
selectedAirport = uiState.selectedOriginAirport, onDateSelect = onDateSelect,
onAirportSelect = onOriginAirportSelect, onAdultCountChange = onAdultCountChange,
modifier = Modifier.fillMaxWidth(), onTeenCountChange = onTeenCountChange,
) onChildCountChange = onChildCountChange,
)
Spacer(modifier = Modifier.height(16.dp))
AirportDropdown(
label = "Destination Airport",
airports = uiState.destinationAirports,
selectedAirport = uiState.selectedDestinationAirport,
onAirportSelect = onDestinationAirportSelect,
modifier = Modifier.fillMaxWidth(),
enabled = uiState.selectedOriginAirport != null,
)
Spacer(modifier = Modifier.height(16.dp))
DatePicker(
label = "Departure Date",
selectedDate = uiState.selectedDate,
onDateSelect = onDateSelect,
modifier = Modifier.fillMaxWidth(),
enabled = uiState.selectedDestinationAirport != null,
)
}
} }
} }
} }
@Composable
private fun ColumnScope.SearchForm(
uiState: HomeUiState.Success,
onOriginAirportSelect: (AirportInfo) -> Unit,
onDestinationAirportSelect: (AirportInfo) -> Unit,
onDateSelect: (LocalDate) -> Unit,
onAdultCountChange: (Int) -> Unit,
onTeenCountChange: (Int) -> Unit,
onChildCountChange: (Int) -> Unit,
) {
AirportDropdown(
label = "Origin Airport",
airports = uiState.originAirports,
selectedAirport = uiState.selectedOriginAirport,
onAirportSelect = onOriginAirportSelect,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
AirportDropdown(
label = "Destination Airport",
airports = uiState.destinationAirports,
selectedAirport = uiState.selectedDestinationAirport,
onAirportSelect = onDestinationAirportSelect,
modifier = Modifier.fillMaxWidth(),
enabled = uiState.selectedOriginAirport != null,
)
Spacer(modifier = Modifier.height(16.dp))
DatePicker(
label = "Departure Date",
selectedDate = uiState.selectedDate,
onDateSelect = onDateSelect,
modifier = Modifier.fillMaxWidth(),
enabled = uiState.selectedDestinationAirport != null,
)
Spacer(modifier = Modifier.height(16.dp))
Text("Passengers")
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(intrinsicSize = IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Counter(
value = uiState.passengers.adultCount,
onCountChange = onAdultCountChange,
label = "Adults",
modifier = Modifier.weight(1f),
minVal = 1,
)
VerticalDivider()
Counter(
value = uiState.passengers.teenCount,
onCountChange = onTeenCountChange,
label = "Teens",
modifier = Modifier.weight(1f),
)
VerticalDivider()
Counter(
value = uiState.passengers.childCount,
onCountChange = onChildCountChange,
label = "Children",
modifier = Modifier.weight(1f),
)
}
}
@PreviewDevices @PreviewDevices
@Composable @Composable
private fun HomeScreenLoadingPreview() { private fun HomeScreenLoadingPreview() {
@ -95,6 +165,9 @@ private fun HomeScreenLoadingPreview() {
onOriginAirportSelect = {}, onOriginAirportSelect = {},
onDestinationAirportSelect = {}, onDestinationAirportSelect = {},
onDateSelect = {}, onDateSelect = {},
onAdultCountChange = {},
onTeenCountChange = {},
onChildCountChange = {},
) )
} }
} }
@ -137,10 +210,18 @@ private fun HomeScreenSuccessPreview() {
selectedOriginAirport = mockAirports.first(), selectedOriginAirport = mockAirports.first(),
selectedDestinationAirport = mockAirports.last(), selectedDestinationAirport = mockAirports.last(),
selectedDate = LocalDate.now(), selectedDate = LocalDate.now(),
passengers = PassengersState(
adultCount = 2,
teenCount = 1,
childCount = 1,
),
), ),
onOriginAirportSelect = {}, onOriginAirportSelect = {},
onDestinationAirportSelect = {}, onDestinationAirportSelect = {},
onDateSelect = {}, onDateSelect = {},
onAdultCountChange = {},
onTeenCountChange = {},
onChildCountChange = {},
) )
} }
} }

View File

@ -10,6 +10,7 @@ import dev.adriankuta.flights.domain.types.AirportInfo
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
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 java.time.LocalDate import java.time.LocalDate
@ -23,12 +24,20 @@ 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(LocalDate.now())
private val adultCount = MutableStateFlow(1)
private val teenCount = MutableStateFlow(0)
private val childCount = MutableStateFlow(0)
internal val uiState = homeUiState( internal val uiState = homeUiState(
useCase = observeAirportsUseCase, useCase = observeAirportsUseCase,
selectedOriginAirport = selectedOriginAirport, selectedOriginAirport = selectedOriginAirport,
selectedDestinationAirport = selectedDestinationAirport, selectedDestinationAirport = selectedDestinationAirport,
selectedDate = selectedDate, selectedDate = selectedDate,
passengersState = passengersState(
adultCount = adultCount,
teenCount = teenCount,
childCount = childCount,
),
) )
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -61,20 +70,37 @@ class HomeScreenViewModel @Inject constructor(
fun selectDate(date: LocalDate) { fun selectDate(date: LocalDate) {
selectedDate.value = date selectedDate.value = date
} }
fun updateAdultCount(diff: Int) {
val newCount = adultCount.value + diff
adultCount.value = newCount
}
fun updateTeenCount(diff: Int) {
val newCount = teenCount.value + diff
teenCount.value = newCount
}
fun updateChildCount(diff: Int) {
val newCount = childCount.value + diff
childCount.value = newCount
}
} }
private fun homeUiState( private fun homeUiState(
useCase: ObserveAirportsUseCase, useCase: ObserveAirportsUseCase,
selectedOriginAirport: MutableStateFlow<AirportInfo?>, selectedOriginAirport: StateFlow<AirportInfo?>,
selectedDestinationAirport: MutableStateFlow<AirportInfo?>, selectedDestinationAirport: StateFlow<AirportInfo?>,
selectedDate: MutableStateFlow<LocalDate>, selectedDate: StateFlow<LocalDate>,
passengersState: Flow<PassengersState>,
): Flow<HomeUiState> { ): Flow<HomeUiState> {
return combine( return combine(
useCase().asResult(), useCase().asResult(),
selectedOriginAirport, selectedOriginAirport,
selectedDestinationAirport, selectedDestinationAirport,
selectedDate, selectedDate,
) { result, origin, destination, date -> passengersState,
) { result, origin, destination, date, passengers ->
when (result) { when (result) {
is Result.Error -> HomeUiState.Error(result.exception) is Result.Error -> HomeUiState.Error(result.exception)
is Result.Loading -> HomeUiState.Loading is Result.Loading -> HomeUiState.Loading
@ -86,12 +112,31 @@ private fun homeUiState(
selectedOriginAirport = origin, selectedOriginAirport = origin,
selectedDestinationAirport = destination, selectedDestinationAirport = destination,
selectedDate = date, selectedDate = date,
passengers = passengers,
) )
} }
} }
} }
} }
private fun passengersState(
adultCount: StateFlow<Int>,
teenCount: StateFlow<Int>,
childCount: StateFlow<Int>,
): Flow<PassengersState> {
return combine(
adultCount,
teenCount,
childCount,
) { adults, teens, children ->
PassengersState(
adultCount = adults,
teenCount = teens,
childCount = children,
)
}
}
internal sealed interface HomeUiState { internal sealed interface HomeUiState {
data object Loading : HomeUiState data object Loading : HomeUiState
data class Success( data class Success(
@ -100,7 +145,14 @@ internal sealed interface HomeUiState {
val selectedOriginAirport: AirportInfo? = null, val selectedOriginAirport: AirportInfo? = null,
val selectedDestinationAirport: AirportInfo? = null, val selectedDestinationAirport: AirportInfo? = null,
val selectedDate: LocalDate = LocalDate.now(), val selectedDate: LocalDate = LocalDate.now(),
val passengers: PassengersState,
) : HomeUiState ) : HomeUiState
data class Error(val exception: Throwable) : HomeUiState data class Error(val exception: Throwable) : HomeUiState
} }
internal data class PassengersState(
val adultCount: Int,
val teenCount: Int,
val childCount: Int,
)

View File

@ -37,6 +37,7 @@ fun Counter(
onCountChange: (diff: Int) -> Unit, onCountChange: (diff: Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
label: String? = null, label: String? = null,
minVal: Int = 0,
) { ) {
val view = LocalView.current val view = LocalView.current
@ -86,11 +87,12 @@ fun Counter(
onCountChange(-1) onCountChange(-1)
}, },
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
enabled = value > 0, enabled = value > minVal,
) { ) {
Text( Text(
text = "-", text = "-",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
) )
} }
Button( Button(
@ -105,6 +107,7 @@ fun Counter(
Text( Text(
text = "+", text = "+",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
) )
} }
} }