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
* 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_
* If you had more time (or if you were doing it in production) what would you have changed/improved?
* Which parts of the code would you like to highlight?
* In your opinion, which Google library was the best addition to the Android Dev world in the last
years?
* _How long did you spend on the task?_
~16h
* _Rate from 0-10, how would you assess the complexity of the task? _0 - very trivial_, _10 -
extremely complex__
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 🙂
> ...
> ...
>
> 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

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>