Compare commits

...

2 Commits

Author SHA1 Message Date
Adrian Kuta
d7253b1218 Update README.md 2025-06-16 09:37:26 +02:00
Adrian Kuta
8d850c72a8 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`).
2025-06-16 09:21:57 +02:00
6 changed files with 136 additions and 20 deletions

View File

@@ -64,17 +64,41 @@ ___
Please submit the answers to the following questions in the README.md file Please submit the answers to the following questions in the README.md file
* How long did you spend on the task? * _How long did you spend on the task?_
* Rate from 0-10, how would you assess the complexity of the task? _0 - very trivial_, _10 -
extremely complex_ ~16h
* If you had more time (or if you were doing it in production) what would you have changed/improved? * _Rate from 0-10, how would you assess the complexity of the task? _0 - very trivial_, _10 -
* Which parts of the code would you like to highlight? extremely complex__
* In your opinion, which Google library was the best addition to the Android Dev world in the last
years? Difficulty - 2
Complexity - 7
* _If you had more time (or if you were doing it in production) what would you have
changed/improved?_
* I would remove hardcoded text values.
* I would add more tests.
* Improve `Cache`, to use also Room as cache e.g. for Airports, instead of memory cache. Right
there is no refresh mechanism for cache.
* Improve sort and group logic for Stations
* Of course UI, it can be more adaptive. Split UI implementation into smaller reusable
Composables.
* _Which parts of the code would you like to highlight?_
* App architecture - multimodularity, separation of concerns, etc.
* _In your opinion, which Google library was the best addition to the Android Dev world in the last
years?_
* In my opinion, Jetpack Compose has been the most impactful and transformative Google library
added to Android development in recent years.
> If you have the need to comment the provided task, we would love to hear that from you 🙂 > If you have the need to comment the provided task, we would love to hear that from you 🙂
> ... >
> ... > The idea behind the task was quite straightforward, and I didnt encounter any major difficulties
> during the implementation.
> However, given the limited time, I had to make some compromises, and the current solution is
> certainly not perfect — a keen eye might spot some flaws. That said, I hope to demonstrate that the
> application is well thought out in terms of its architecture, which was my main focus.
> Each area — UI, Domain, and Model — has its own dedicated space with appropriate layers of
> abstraction and dependency injection. In short, its a compact example of applying SOLID principles
> in practice.
### Time frame ### Time frame

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,11 +55,19 @@ 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 -> {
if (uiState.flight.hasFlights()) {
itemsIndexed(uiState.flight.trips) { index, trip ->
TripCard(trip = trip, currency = uiState.flight.currency) TripCard(trip = trip, currency = uiState.flight.currency)
if (index < uiState.flight.trips.lastIndex) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }
} else {
item { EmptyListComponent() }
}
}
}
} }
} }
@@ -78,14 +93,16 @@ 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)
if (index != trip.dates.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
} }
} }
} }
} }
} }
}
@Composable @Composable
private fun TripDateItem( 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) @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,10 +71,22 @@ private fun StationsScreen(
LaunchedEffect(selectedOriginAirport) { LaunchedEffect(selectedOriginAirport) {
selectedOriginAirport?.let { airportInfo = it } selectedOriginAirport?.let { airportInfo = it }
} }
Column {
Text(
text = "Origin airport",
modifier = Modifier.padding(horizontal = 16.dp),
)
AirportInfoCompact( AirportInfoCompact(
data = airportInfo, data = airportInfo,
) )
} }
}
val hintRes = if (selectedOriginAirport == null) {
R.string.search_origin_airport
} else {
R.string.search_destination_airport
}
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = searchQuery,
@@ -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>