Refactor: Improve flight search UI and logic

This commit introduces several enhancements to the flight search user interface and underlying logic.

Key changes:
- Added an extension function `Flight.hasFlights()` to check if a flight object contains any actual flight data. This is located in `ui/home/src/main/kotlin/dev/adriankuta/flights/ui/home/extensions/FlightExt.kt`.
- In `StationsScreen.kt`:
    - The origin airport display now includes a "Origin airport" label.
    - The search input field placeholder text dynamically changes based on whether an origin airport has been selected (e.g., "Search origin airport" vs. "Search destination airport").
- In `StationsScreenViewModel.kt`:
    - When an airport is selected, the search query is now automatically cleared.
- Added new string resources in `ui/stations/src/main/res/values/strings.xml` for the dynamic placeholder text: `search_origin_airport` and `search_destination_airport`.
- In `SearchResults.kt`:
    - Implemented an `EmptyListComponent` to display a user-friendly message with an icon when no flights are found for the given search criteria.
    - The `SearchResults` composable now utilizes `itemsIndexed` for better handling of dividers between trip cards.
    - Dividers are no longer shown after the last item in the list of trips or trip dates.
    - Added previews for the empty list state (`EmptyListPreview` and `SearchResultsEmptyPreview`).
This commit is contained in:
2025-06-16 09:21:57 +02:00
parent 8ce553240c
commit 8d850c72a8
5 changed files with 103 additions and 11 deletions

View File

@ -1,22 +1,28 @@
package dev.adriankuta.flights.ui.home.components package dev.adriankuta.flights.ui.home.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -25,6 +31,7 @@ import dev.adriankuta.flights.domain.types.Trip
import dev.adriankuta.flights.domain.types.TripDate import dev.adriankuta.flights.domain.types.TripDate
import dev.adriankuta.flights.domain.types.TripFlight import dev.adriankuta.flights.domain.types.TripFlight
import dev.adriankuta.flights.ui.home.SearchResultUiState import dev.adriankuta.flights.ui.home.SearchResultUiState
import dev.adriankuta.flights.ui.home.extensions.hasFlights
import dev.adriankuta.flights.ui.home.sample.FlightPreviewParameterProvider import dev.adriankuta.flights.ui.home.sample.FlightPreviewParameterProvider
@Composable @Composable
@ -48,9 +55,17 @@ internal fun SearchResults(
is SearchResultUiState.Error -> item { Text("Error") } is SearchResultUiState.Error -> item { Text("Error") }
SearchResultUiState.Idle -> Unit SearchResultUiState.Idle -> Unit
SearchResultUiState.Loading -> item { Text("Loading") } SearchResultUiState.Loading -> item { Text("Loading") }
is SearchResultUiState.Success -> items(uiState.flight.trips) { trip -> is SearchResultUiState.Success -> {
TripCard(trip = trip, currency = uiState.flight.currency) if (uiState.flight.hasFlights()) {
Spacer(modifier = Modifier.height(16.dp)) itemsIndexed(uiState.flight.trips) { index, trip ->
TripCard(trip = trip, currency = uiState.flight.currency)
if (index < uiState.flight.trips.lastIndex) {
Spacer(modifier = Modifier.height(16.dp))
}
}
} else {
item { EmptyListComponent() }
}
} }
} }
} }
@ -78,9 +93,11 @@ private fun TripCard(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Column { Column {
trip.dates.forEach { tripDate -> trip.dates.forEachIndexed { index, tripDate ->
TripDateItem(tripDate = tripDate, currency = currency) TripDateItem(tripDate = tripDate, currency = currency)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) if (index != trip.dates.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
} }
} }
} }
@ -163,8 +180,63 @@ private fun FlightItem(
} }
} }
@Composable
private fun EmptyListComponent(
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.padding(bottom = 16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "No flights found",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Try changing your search criteria",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun EmptyListPreview() {
EmptyListComponent()
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun SearchResultsPreview(@PreviewParameter(FlightPreviewParameterProvider::class) flight: Flight) { private fun SearchResultsPreview(@PreviewParameter(FlightPreviewParameterProvider::class) flight: Flight) {
SearchResults(uiState = SearchResultUiState.Success(flight)) SearchResults(uiState = SearchResultUiState.Success(flight))
} }
@Preview(showBackground = true)
@Composable
private fun SearchResultsEmptyPreview() {
SearchResults(
uiState = SearchResultUiState.Success(
Flight(
currency = "EUR",
currPrecision = 2,
trips = emptyList(),
),
),
)
}

View File

@ -0,0 +1,6 @@
package dev.adriankuta.flights.ui.home.extensions
import dev.adriankuta.flights.domain.types.Flight
internal fun Flight.hasFlights(): Boolean =
trips.firstOrNull()?.dates?.firstOrNull()?.flights?.isNotEmpty() == true

View File

@ -71,9 +71,21 @@ private fun StationsScreen(
LaunchedEffect(selectedOriginAirport) { LaunchedEffect(selectedOriginAirport) {
selectedOriginAirport?.let { airportInfo = it } selectedOriginAirport?.let { airportInfo = it }
} }
AirportInfoCompact( Column {
data = airportInfo, Text(
) text = "Origin airport",
modifier = Modifier.padding(horizontal = 16.dp),
)
AirportInfoCompact(
data = airportInfo,
)
}
}
val hintRes = if (selectedOriginAirport == null) {
R.string.search_origin_airport
} else {
R.string.search_destination_airport
} }
OutlinedTextField( OutlinedTextField(
@ -82,7 +94,7 @@ private fun StationsScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
placeholder = { Text(text = stringResource(R.string.search_airports)) }, placeholder = { Text(text = stringResource(hintRes)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true, singleLine = true,
) )

View File

@ -45,6 +45,7 @@ internal class StationsScreenViewModel @Inject constructor(
fun onAirportSelected(airport: AirportInfo) { fun onAirportSelected(airport: AirportInfo) {
_selectedOriginAirport.value = airport _selectedOriginAirport.value = airport
_searchQuery.value = ""
} }
fun clearSelectedAirport() { fun clearSelectedAirport() {

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="stations_screen_title">Stations</string> <string name="stations_screen_title">Stations</string>
<string name="search_airports">Search airports</string> <string name="search_origin_airport">Search origin airport</string>
<string name="search_destination_airport">Search destination airport</string>
</resources> </resources>