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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.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.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
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.TripFlight
import dev.adriankuta.flights.ui.home.SearchResultUiState
import dev.adriankuta.flights.ui.home.extensions.hasFlights
import dev.adriankuta.flights.ui.home.sample.FlightPreviewParameterProvider
@Composable
@ -48,11 +55,19 @@ internal fun SearchResults(
is SearchResultUiState.Error -> item { Text("Error") }
SearchResultUiState.Idle -> Unit
SearchResultUiState.Loading -> item { Text("Loading") }
is SearchResultUiState.Success -> items(uiState.flight.trips) { trip ->
is SearchResultUiState.Success -> {
if (uiState.flight.hasFlights()) {
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,14 +93,16 @@ private fun TripCard(
Spacer(modifier = Modifier.height(8.dp))
Column {
trip.dates.forEach { tripDate ->
trip.dates.forEachIndexed { index, tripDate ->
TripDateItem(tripDate = tripDate, currency = currency)
if (index != trip.dates.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
}
}
}
}
}
@Composable
private fun TripDateItem(
@ -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)
@Composable
private fun SearchResultsPreview(@PreviewParameter(FlightPreviewParameterProvider::class) flight: 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,10 +71,22 @@ private fun StationsScreen(
LaunchedEffect(selectedOriginAirport) {
selectedOriginAirport?.let { airportInfo = it }
}
Column {
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(
value = searchQuery,
@ -82,7 +94,7 @@ private fun StationsScreen(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
placeholder = { Text(text = stringResource(R.string.search_airports)) },
placeholder = { Text(text = stringResource(hintRes)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
)

View File

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

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<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>