mirror of
				https://github.com/AdrianKuta/android-challange-adrian-kuta.git
				synced 2025-10-31 06:13:38 +01:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			8ce553240c
			...
			d7253b1218
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | d7253b1218 | ||
|   | 8d850c72a8 | 
							
								
								
									
										42
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								README.md
									
									
									
									
									
								
							| @@ -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 didn’t 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, it’s a compact example of applying SOLID principles | ||||||
|  | > in practice. | ||||||
|  |  | ||||||
| ### Time frame | ### Time frame | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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(), | ||||||
|  |             ), | ||||||
|  |         ), | ||||||
|  |     ) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -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 | ||||||
| @@ -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, | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -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() { | ||||||
|   | |||||||
| @@ -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> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user