diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6defdd1..6dc5ac0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) diff --git a/app/src/androidTest/kotlin/data/local/dao/AirportDAOTest.kt b/app/src/androidTest/kotlin/data/local/dao/AirportDAOTest.kt index d5b395b..05435d5 100644 --- a/app/src/androidTest/kotlin/data/local/dao/AirportDAOTest.kt +++ b/app/src/androidTest/kotlin/data/local/dao/AirportDAOTest.kt @@ -34,6 +34,20 @@ private val airport2 = Airport( 7494765 ) +// Sample sorted data by passengers from the actual database to test sorting +private val airport3 = Airport( + 28, + "Munich International Airport", + "MUC", + 47959885 +) +private val airport4 = Airport( + 7, + "Sheremetyevo - A.S. Pushkin international airport", + "SVO", + 49933000 +) + @RunWith(AndroidJUnit4::class) class AirportDAOTest { @@ -67,6 +81,18 @@ class AirportDAOTest { assertEquals(allAirports[1], airport2) } + @Test + @Throws(IOException::class) + fun daoGetAllAirportsOrderedByPassengers_returnAllAirportsWithCorrectSorting() = runBlocking { + val allAirports = airportDAO.getAllAirportsOrderedByPassengers().first() + + assertTrue(allAirports.isNotEmpty()) + + assertTrue(airport3.passengers < airport4.passengers) // True So airport4 must be first + assertEquals(allAirports[0], airport4) + assertEquals(allAirports[1], airport3) + } + @Test @Throws(IOException::class) fun daoGetAirportByCode_returnSingleAirport() = runBlocking { diff --git a/app/src/main/java/com/nabilbdev/searchflight/MainActivity.kt b/app/src/main/java/com/nabilbdev/searchflight/MainActivity.kt index a125b76..84a5692 100644 --- a/app/src/main/java/com/nabilbdev/searchflight/MainActivity.kt +++ b/app/src/main/java/com/nabilbdev/searchflight/MainActivity.kt @@ -4,12 +4,9 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.nabilbdev.searchflight.ui.theme.SearchFlightTheme @@ -23,12 +20,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(text = "Room database is ready!") - } + SearchFlightApp() } } } diff --git a/app/src/main/java/com/nabilbdev/searchflight/SearchFlightApp.kt b/app/src/main/java/com/nabilbdev/searchflight/SearchFlightApp.kt new file mode 100644 index 0000000..b222dd1 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/SearchFlightApp.kt @@ -0,0 +1,42 @@ +package com.nabilbdev.searchflight + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.nabilbdev.searchflight.ui.AppViewModelProvider +import com.nabilbdev.searchflight.ui.screens.search.LoadUiState +import com.nabilbdev.searchflight.ui.screens.search.MySearchBar +import com.nabilbdev.searchflight.ui.screens.search.SearchScreen +import com.nabilbdev.searchflight.ui.screens.search.SearchScreenViewModel +import com.nabilbdev.searchflight.ui.screens.search.SearchUiState +import com.nabilbdev.searchflight.ui.screens.search.SelectUiState + +@Composable +fun SearchFlightApp() { + + val viewModel: SearchScreenViewModel = viewModel(factory = AppViewModelProvider.Factory) + val searchUiState: SearchUiState = viewModel.searchUiState.collectAsState().value + val selectUiState: SelectUiState = viewModel.selectUiState.collectAsState().value + val loadUiState: LoadUiState = viewModel.loadUiState.collectAsState().value + + Scaffold( + topBar = { + MySearchBar( + query = searchUiState.searchQuery, + errorMessage = loadUiState.errorMessage, + allAirportsList = selectUiState.allAirportList, + airportListByQuery = searchUiState.airportListByQuery, + viewModel = viewModel + ) + } + ) { innerPadding -> + SearchScreen( + popularCitiesAirports = searchUiState.popularCityAirports, + isLoadingAirports = loadUiState.isLoadingPopularCityAirports, + modifier = Modifier.padding(innerPadding) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nabilbdev/searchflight/data/local/dao/AirportDAO.kt b/app/src/main/java/com/nabilbdev/searchflight/data/local/dao/AirportDAO.kt index 5bf2508..f94e0d6 100644 --- a/app/src/main/java/com/nabilbdev/searchflight/data/local/dao/AirportDAO.kt +++ b/app/src/main/java/com/nabilbdev/searchflight/data/local/dao/AirportDAO.kt @@ -21,4 +21,7 @@ interface AirportDAO { @Query("SELECT * FROM airport") fun getAllAirports(): Flow> + + @Query("SELECT * FROM airport ORDER BY passengers DESC") + fun getAllAirportsOrderedByPassengers(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/nabilbdev/searchflight/data/local/repository/SearchFlightRepository.kt b/app/src/main/java/com/nabilbdev/searchflight/data/local/repository/SearchFlightRepository.kt index af9c547..9dc2235 100644 --- a/app/src/main/java/com/nabilbdev/searchflight/data/local/repository/SearchFlightRepository.kt +++ b/app/src/main/java/com/nabilbdev/searchflight/data/local/repository/SearchFlightRepository.kt @@ -18,6 +18,8 @@ interface SearchFlightRepository { fun getAllAirportsStream(): Flow> + fun getAllAirportsOrderedByPassengersStream(): Flow> + suspend fun insertFavoriteAirport(favorite: Favorite) suspend fun deleteFavoriteAirport(favorite: Favorite) @@ -43,6 +45,9 @@ class OfflineSearchFlightRepository( override fun getAllAirportsStream(): Flow> = airportDAO.getAllAirports() + override fun getAllAirportsOrderedByPassengersStream(): Flow> = + airportDAO.getAllAirportsOrderedByPassengers() + override suspend fun insertFavoriteAirport(favorite: Favorite) = favoriteDAO.insert(favorite) diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/AppViewModelProvider.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/AppViewModelProvider.kt new file mode 100644 index 0000000..6324e4a --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/AppViewModelProvider.kt @@ -0,0 +1,29 @@ +package com.nabilbdev.searchflight.ui + +import android.app.Application +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.nabilbdev.searchflight.SearchFlightApplication +import com.nabilbdev.searchflight.ui.screens.search.SearchScreenViewModel + +/** + * Provides Factory to create instance of ViewModel for the entire SearchFlight app + */ +object AppViewModelProvider { + val Factory = viewModelFactory { + initializer { + SearchScreenViewModel( + searchFlightRepository = searchFLightApplication().container.searchFlightRepository + ) + } + } +} + +/** + * Extension function to queries for [Application] object and returns an instance of + * [SearchFlightApplication]. + */ +fun CreationExtras.searchFLightApplication(): SearchFlightApplication = + (this[AndroidViewModelFactory.APPLICATION_KEY] as SearchFlightApplication) diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/components/AirportCard.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/components/AirportCard.kt new file mode 100644 index 0000000..d5a3279 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/components/AirportCard.kt @@ -0,0 +1,67 @@ +package com.nabilbdev.searchflight.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.nabilbdev.searchflight.data.local.entity.Airport + +@Composable +fun AirportCard( + airport: Airport, + imageVector: ImageVector, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .aspectRatio(4f / 2f), + shape = CardDefaults.elevatedShape + ) { + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + ) { + Text( + text = airport.name, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .weight(0.5f) + ) + Box( + modifier = Modifier + .weight(0.2f), + contentAlignment = Alignment.TopEnd + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(20.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/components/BasicVerticalGrid.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/components/BasicVerticalGrid.kt new file mode 100644 index 0000000..4eb78ad --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/components/BasicVerticalGrid.kt @@ -0,0 +1,73 @@ +package com.nabilbdev.searchflight.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.material3.LinearProgressIndicator +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.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.nabilbdev.searchflight.data.local.entity.Airport + +@Composable +fun CommonAirportVerticalGrid( + titleContent: String, + airportList: List, + imageVector: ImageVector, + modifier: Modifier = Modifier, + isLoadingAirports: Boolean = false +) { + AnimatedVisibility(visible = isLoadingAirports, exit = fadeOut()) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LinearProgressIndicator() + } + } + AnimatedVisibility(visible = !isLoadingAirports, enter = fadeIn()) { + CustomVerticalGrid( + titleContent = titleContent, + airportList = airportList, + imageVector = imageVector + ) + } +} + +/** + * Create a custom grid layout using a combination of Row and Column + */ +@Composable +fun CustomVerticalGrid( + titleContent: String, + airportList: List, + imageVector: ImageVector, + modifier: Modifier = Modifier +) { + Column { + Text(text = titleContent, style = MaterialTheme.typography.titleLarge) + Spacer(modifier = modifier.height(8.dp)) + airportList.chunked(2).forEach { halfList -> + Row(modifier = modifier.fillMaxWidth()) { + halfList.forEach { airport -> + AirportCard( + airport = airport, + imageVector = imageVector, + modifier = Modifier + .weight(1f) + .padding(4.dp) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/components/CustomCicularChart.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/components/CustomCicularChart.kt new file mode 100644 index 0000000..ceb5068 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/components/CustomCicularChart.kt @@ -0,0 +1,140 @@ +package com.nabilbdev.searchflight.ui.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nabilbdev.searchflight.ui.theme.SearchFlightTheme + +@Composable +fun CustomCircularChart( + canvasSize: Dp = 128.dp, + indicatorValuePercentage: Int = 0, // by default is the passengerPercentage + bgIndicatorColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), + bgIndicatorStrokeWidth: Float = 15f, + normalFgIndicatorColor: Color = MaterialTheme.colorScheme.primary.copy(blue = 1f), + errorFgIndicatorColor: Color = MaterialTheme.colorScheme.error, + successFgIndicatorColor: Color = MaterialTheme.colorScheme.primary.copy(green = 0.87f), + content: @Composable () -> Unit = {} +) { + + /** + * This `animatedIndicatorValuePercentage`: + * Recompose whenever `indicatorValuePercentage` is changed + */ + var animatedIndicatorValuePercentage by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(key1 = indicatorValuePercentage) { + animatedIndicatorValuePercentage = (indicatorValuePercentage.toFloat()) + } + + val sweepAngle by animateFloatAsState( + targetValue = (2.4 * animatedIndicatorValuePercentage).toFloat(), + animationSpec = tween(1000), + label = "", + ) + + Column( + modifier = Modifier + .size(canvasSize) + .drawBehind { + // canvasSize divided by 1.25f (similar to setting a padding) + val componentSize = size / 1.25f + backgroundIndicator( + componentSize = componentSize, + indicatorColor = bgIndicatorColor, + indicatorStrokeWidth = bgIndicatorStrokeWidth, + ) + foregroundIndicator( + sweepAngle = sweepAngle, + componentSize = componentSize, + indicatorColor = when (indicatorValuePercentage) { + in 0..25 -> errorFgIndicatorColor + in 26..50 -> normalFgIndicatorColor + else -> successFgIndicatorColor + }, + indicatorStrokeWidth = bgIndicatorStrokeWidth, + ) + }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + content() + } +} + +fun DrawScope.backgroundIndicator( + componentSize: Size, + indicatorColor: Color, + indicatorStrokeWidth: Float, +) { + drawArc( + size = componentSize, + color = indicatorColor, + startAngle = 150f, + sweepAngle = 240f, + useCenter = false, + style = Stroke( + width = indicatorStrokeWidth, + cap = StrokeCap.Round + ), + topLeft = Offset( + x = (size.width - componentSize.width) / 2, + y = (size.height - componentSize.height) / 2, + ) + ) +} + +fun DrawScope.foregroundIndicator( + sweepAngle: Float, + componentSize: Size, + indicatorColor: Color, + indicatorStrokeWidth: Float, +) { + drawArc( + size = componentSize, + color = indicatorColor, + startAngle = 150f, + sweepAngle = sweepAngle, + useCenter = false, + style = Stroke( + width = indicatorStrokeWidth, + cap = StrokeCap.Round + ), + topLeft = Offset( + x = (size.width - componentSize.width) / 2, + y = (size.height - componentSize.height) / 2, + ) + ) +} + + +@Preview(showBackground = true) +@Composable +fun CustomCircularChartPreview() { + SearchFlightTheme { + CustomCircularChart() + } +} + + diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/components/ErrorMessage.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/components/ErrorMessage.kt new file mode 100644 index 0000000..cddaab7 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/components/ErrorMessage.kt @@ -0,0 +1,65 @@ +package com.nabilbdev.searchflight.ui.components + +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ErrorMessage( + errorMessage: String, + errorIconId: Int, + modifier: Modifier = Modifier +) { + + // Infinite transition for the scaling animation + val infiniteTransition = rememberInfiniteTransition(label = "") + val scale: Float by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.3f, // Scale between 1x and 1.3x + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutLinearInEasing), + repeatMode = RepeatMode.Reverse + ), label = "" + ) + + Column( + modifier = modifier + .fillMaxSize() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = painterResource(id = errorIconId), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(100.dp) + .padding(bottom = 20.dp) + .scale(scale) + ) + Text( + text = errorMessage, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/components/FilterButton.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/components/FilterButton.kt new file mode 100644 index 0000000..ace386d --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/components/FilterButton.kt @@ -0,0 +1,23 @@ +package com.nabilbdev.searchflight.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.nabilbdev.searchflight.R + +@Composable +fun FilterButton( + modifier: Modifier = Modifier, + onFilter: () -> Unit = {} +) { + IconButton(onClick = onFilter) { + Icon( + painter = painterResource(id = R.drawable.filter_list), contentDescription = null, + modifier = modifier.size(50.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/components/FilterChipWrapper.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/components/FilterChipWrapper.kt new file mode 100644 index 0000000..e395ea4 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/components/FilterChipWrapper.kt @@ -0,0 +1,36 @@ +package com.nabilbdev.searchflight.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun FilterChipWrapper( + selected: Boolean = false, + label: String = "" +) { + FilterChip( + selected = selected, + onClick = { selected != selected }, + label = { Text(text = label) }, + leadingIcon = if (selected) { + { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = "Done icon", + modifier = Modifier.size( + FilterChipDefaults.IconSize + ) + ) + } + } else { + null + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/SearchScreen.kt new file mode 100644 index 0000000..085b0f7 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/SearchScreen.kt @@ -0,0 +1,288 @@ +package com.nabilbdev.searchflight.ui.screens.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +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.unit.dp +import com.nabilbdev.searchflight.R +import com.nabilbdev.searchflight.data.local.entity.Airport +import com.nabilbdev.searchflight.ui.components.CommonAirportVerticalGrid +import com.nabilbdev.searchflight.ui.components.CustomCircularChart +import com.nabilbdev.searchflight.ui.components.ErrorMessage +import com.nabilbdev.searchflight.ui.components.FilterButton +import com.nabilbdev.searchflight.ui.components.FilterChipWrapper +import com.nabilbdev.searchflight.ui.screens.search.utils.AIRPORT_DEFAULT +import com.nabilbdev.searchflight.ui.theme.SearchFlightTheme + +@Composable +fun SearchScreen( + popularCitiesAirports: List, + isLoadingAirports: Boolean, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + // TODO("Recent Searches") + // TODO("Favorites") + Spacer(modifier = Modifier.height(8.dp)) + CommonAirportVerticalGrid( + titleContent = "Popular Cities Flights", + airportList = popularCitiesAirports, + isLoadingAirports = isLoadingAirports, + imageVector = Icons.Outlined.Search + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MySearchBar( + query: String, + errorMessage: String?, + allAirportsList: List, + airportListByQuery: List, + viewModel: SearchScreenViewModel, + modifier: Modifier = Modifier +) { + var active by remember { mutableStateOf(false) } + + SearchBar( + modifier = when (active) { + false -> modifier + .fillMaxWidth() + .padding(12.dp) + + else -> modifier.fillMaxSize() + }, + enabled = true, + query = query, + onQueryChange = { newWord -> viewModel.setNewSearchQuery(newWord) }, + onSearch = { + // Navigate to search routes + }, + active = active, + onActiveChange = { inactive -> active = inactive }, + placeholder = { Text(text = "Search departures...") }, + leadingIcon = { + if (active) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + modifier = Modifier.clickable { + viewModel.clearSearch() + active = false + } + ) + } else { + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = "Search" + ) + } + }, + trailingIcon = { + if (active) { + Icon( + imageVector = Icons.Outlined.Clear, + contentDescription = "Clear your search", + modifier = Modifier.clickable { + // To clear the user's typing search + viewModel.clearSearch() + } + ) + } + } + ) { + if (query.isEmpty()) { + LazyColumn(modifier = Modifier.padding(8.dp)) { + items(airportListByQuery) { airport -> + SearchResultBody( + airport = airport, + passengerNumber = viewModel.passengerNumWrapper(airport.passengers.toLong()), + passengerPercentage = viewModel.getPassengerNumberPercentage(airport.passengers) + ) + HorizontalDivider() + } + } + } + when (errorMessage) { + null -> { + when (query) { + "" -> { + FilterSelection() + LazyColumn(modifier = Modifier.padding(8.dp)) { + items(allAirportsList) { airport -> + SearchResultBody( + airport = airport, + passengerNumber = viewModel.passengerNumWrapper(airport.passengers.toLong()), + passengerPercentage = viewModel.getPassengerNumberPercentage( + airport.passengers + ) + ) + HorizontalDivider() + } + } + } + + else -> { + LazyColumn(modifier = Modifier.padding(8.dp)) { + items(airportListByQuery) { airport -> + SearchResultBody( + airport = airport, + passengerNumber = viewModel.passengerNumWrapper(airport.passengers.toLong()), + passengerPercentage = viewModel.getPassengerNumberPercentage( + airport.passengers + ) + ) + HorizontalDivider() + } + } + } + } + } + + else -> ErrorMessage(errorMessage = errorMessage, errorIconId = R.drawable.flight_error) + } + } +} + +@Composable +fun FilterSelection( + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally) + ) { + FilterChipWrapper(label = "Most Visited") + FilterChipWrapper(label = "By Name") + FilterButton( + modifier = Modifier.padding(start = 12.dp) + ) + } +} + +@Composable +fun SearchResultBody( + airport: Airport, + passengerNumber: String, + passengerPercentage: Int, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .padding(8.dp) + .fillMaxWidth() + .wrapContentHeight(Alignment.CenterVertically), + shape = CardDefaults.elevatedShape + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + Column( + modifier = Modifier + .padding(8.dp) + .weight(0.6f), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.flight_from), + contentDescription = null + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "From", + color = MaterialTheme.colorScheme.secondary + ) + } + Text( + text = airport.name, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge + ) + } + Box( + modifier = Modifier + .padding(8.dp) + .weight(0.5f), + contentAlignment = Alignment.TopEnd + ) { + CustomCircularChart(indicatorValuePercentage = passengerPercentage) { + Text( + text = "Passengers", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + Text( + text = passengerNumber, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + + +@Preview(showSystemUi = true) +@Composable +fun SearchResultBodyPreview() { + SearchFlightTheme { + SearchResultBody( + airport = AIRPORT_DEFAULT, + passengerPercentage = 9, + passengerNumber = "1.4M" + ) + } +} diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/SearchScreenViewModel.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/SearchScreenViewModel.kt new file mode 100644 index 0000000..eaf3867 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/SearchScreenViewModel.kt @@ -0,0 +1,190 @@ +package com.nabilbdev.searchflight.ui.screens.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nabilbdev.searchflight.data.local.entity.Airport +import com.nabilbdev.searchflight.data.local.repository.SearchFlightRepository +import com.nabilbdev.searchflight.ui.screens.search.utils.popularCitiesAirportCodeList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Locale +import kotlin.math.round + + +data class SearchUiState( + val searchQuery: String = "", + val airportListByQuery: List = emptyList(), + val popularCityAirports: List = emptyList(), +) + +data class SelectUiState( + val allAirportList: List = emptyList() +) + +data class LoadUiState( + val isLoadingPopularCityAirports: Boolean = true, + val errorMessage: String? = null +) + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +class SearchScreenViewModel( + private val searchFlightRepository: SearchFlightRepository +) : ViewModel() { + + /** + * A set of observable data holders: holds data from flows observed in Room DB. + */ + private val _searchQuery = MutableStateFlow("") + private val _allAirportList = MutableStateFlow>(emptyList()) + private val _airportListByQuery = MutableStateFlow>(emptyList()) + private val _popularCitiesAirport = MutableStateFlow>(emptyList()) + private val _isLoadingPopularCityAirports = MutableStateFlow(true) + private val _errorMessage = MutableStateFlow(null) + private var _maxPassengerNumber = MutableStateFlow(0) + + + /** + * A Ui State that expose updates related to filtering and get airports list to the UI + */ + val selectUiState: StateFlow = _allAirportList + .mapLatest { + when { + _searchQuery.value.isEmpty() -> { + _allAirportList.value = searchFlightRepository.getAllAirportsStream() + .first() + .onEach { airport -> setMaxPassengerNumber(airport.passengers) } + } + + else -> _allAirportList.value = emptyList() + } + + SelectUiState(allAirportList = _allAirportList.value) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), SelectUiState()) + + /** + * A Ui State that expose updates related to search and airports list to the UI + */ + val searchUiState: StateFlow = combine( + _searchQuery, + _airportListByQuery, + _popularCitiesAirport, + ) { searchQuery, airportListByQuery, popularCityAirports -> + + if (searchQuery.isBlank()) + clearSearch() + + SearchUiState( + searchQuery = searchQuery, + airportListByQuery = airportListByQuery, + popularCityAirports = popularCityAirports, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), SearchUiState()) + + + /** + * A Ui State that expose updates related to the loading & error state of the airports lists to the UI + */ + val loadUiState: StateFlow = combine( + _isLoadingPopularCityAirports, + _errorMessage + ) { isLoadingPopularCity, errorMessage -> + LoadUiState( + isLoadingPopularCityAirports = isLoadingPopularCity, + errorMessage = errorMessage + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), LoadUiState()) + + init { + clearSearch() + fetchPopularCityAirports() + + // Observe search query and update the airport list accordingly + _searchQuery + .debounce(300) + .filter { it.length > 1 } + .distinctUntilChanged() + .onEach { query -> + _airportListByQuery.value = + searchFlightRepository.getAirportsByQueryStream("%$query%") + .first() + .onEach { airport -> setMaxPassengerNumber(airport.passengers) } + + _errorMessage.value = when (_airportListByQuery.value.size) { + 0 -> "Oops, we can't find this in our flight database!" + else -> null + } + } + .launchIn(viewModelScope) + } + + /** + * Get a list of airports based onf popular cities airport code [popularCitiesAirportCodeList] + */ + private fun fetchPopularCityAirports() { + viewModelScope.launch { + val airports = popularCitiesAirportCodeList.map { cityCode -> + searchFlightRepository.getAirportByCodeStream(cityCode).filterNotNull().first() + } + _popularCitiesAirport.value = airports + _isLoadingPopularCityAirports.value = false + } + } + + private fun setMaxPassengerNumber(passengers: Int) { + _maxPassengerNumber.value = + maxOf(passengers, _maxPassengerNumber.value) + } + + /** + * TODO: Getting recent Searches of the user. + */ + + fun setNewSearchQuery(query: String) { + _searchQuery.value = query + } + + fun clearSearch() { + _searchQuery.value = "" + _errorMessage.value = null + _airportListByQuery.value = emptyList() + } + + /** + * Format the passenger number in more readable format: + * instead of `1000000` we do `1M` + */ + fun passengerNumWrapper(passengers: Long): String { + return when { + passengers >= 1_000_000 -> String.format(Locale.US, "%.1fM", passengers / 1_000_000.0) + passengers >= 1_000 -> String.format(Locale.US, "%.1fk", passengers / 1_000.0) + else -> passengers.toString() + }.replace(".0", "") + } + + /** + * Get the number of passenger in percentage: + * Based on the airport that contains the max number of passengers + */ + fun getPassengerNumberPercentage(passengers: Int): Int { + return round((passengers.toDouble() / _maxPassengerNumber.value) * 100).toInt() + } + + companion object { + private const val TIMEOUT_MILLIS = 5_000L + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/utils/PopularCitiesAirportsCodeUtils.kt b/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/utils/PopularCitiesAirportsCodeUtils.kt new file mode 100644 index 0000000..d83ee87 --- /dev/null +++ b/app/src/main/java/com/nabilbdev/searchflight/ui/screens/search/utils/PopularCitiesAirportsCodeUtils.kt @@ -0,0 +1,14 @@ +package com.nabilbdev.searchflight.ui.screens.search.utils + +import com.nabilbdev.searchflight.data.local.entity.Airport + +// Used for preview +val AIRPORT_DEFAULT = Airport(1, "Los Angeles LAX Airport", "LAX", 100) + +// popular cities code +const val PARIS = "CDG" +const val FRANKFURT = "FRA" +const val AMSTERDAM = "AMS" + +const val VIENNA = "VIE" +val popularCitiesAirportCodeList = listOf(PARIS, AMSTERDAM, FRANKFURT, VIENNA) diff --git a/app/src/main/res/drawable/filter_list.xml b/app/src/main/res/drawable/filter_list.xml new file mode 100644 index 0000000..f239865 --- /dev/null +++ b/app/src/main/res/drawable/filter_list.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/flight_error.xml b/app/src/main/res/drawable/flight_error.xml new file mode 100644 index 0000000..f792103 --- /dev/null +++ b/app/src/main/res/drawable/flight_error.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/flight_from.xml b/app/src/main/res/drawable/flight_from.xml new file mode 100644 index 0000000..069b612 --- /dev/null +++ b/app/src/main/res/drawable/flight_from.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/nabilbdev/searchflight/data/local/fake/FakeAirportDAO.kt b/app/src/test/java/com/nabilbdev/searchflight/data/local/fake/FakeAirportDAO.kt index 9bc0805..8ac520c 100644 --- a/app/src/test/java/com/nabilbdev/searchflight/data/local/fake/FakeAirportDAO.kt +++ b/app/src/test/java/com/nabilbdev/searchflight/data/local/fake/FakeAirportDAO.kt @@ -34,4 +34,8 @@ class FakeAirportDAO : AirportDAO { override fun getAllAirports(): Flow> = flow { emit(airports) } + + override fun getAllAirportsOrderedByPassengers(): Flow> = flow { + emit(airports.sortedByDescending { it.passengers }.take(4)) + } } diff --git a/app/src/test/java/com/nabilbdev/searchflight/data/local/repository/OfflineSearchFlightRepositoryTest.kt b/app/src/test/java/com/nabilbdev/searchflight/data/local/repository/OfflineSearchFlightRepositoryTest.kt index 550a74f..0bbb441 100644 --- a/app/src/test/java/com/nabilbdev/searchflight/data/local/repository/OfflineSearchFlightRepositoryTest.kt +++ b/app/src/test/java/com/nabilbdev/searchflight/data/local/repository/OfflineSearchFlightRepositoryTest.kt @@ -56,6 +56,15 @@ class OfflineSearchFlightRepositoryTest { assertEquals(result, fakeAirports[0]) } + @Test + fun testGetAirportOrderedByPassengers_returnsListOfFourAirport() = runBlocking { + val result = offlineSearchFlightRepo.getAllAirportsOrderedByPassengersStream().first() + + assertEquals(result.size, 4) + assertTrue(result[0].passengers > result[1].passengers) + assertTrue(result[1].passengers > result[2].passengers) + } + @Test fun testGetAllAirports_returnsCompleteListOfAirports() = runBlocking { val result = offlineSearchFlightRepo.getAllAirportsStream().first() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cbf25e4..043090f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ junitKtx = "1.2.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" }