From 46f6142ba1454f61aa25a6a51dad3f8dab4a5446 Mon Sep 17 00:00:00 2001 From: Alexei Belikov <11338016+balexei@users.noreply.github.com> Date: Thu, 21 Nov 2024 23:22:37 +0100 Subject: [PATCH] refactor: create specific viewmodel for 'nearby' screen and improve permission handling --- app/build.gradle.kts | 1 + .../balexei/vitrasaparada/ui/MainActivity.kt | 31 --------- .../balexei/vitrasaparada/ui/NavGraph.kt | 4 +- .../vitrasaparada/ui/nearby/NearbyRoute.kt | 56 +++++++++++++++-- .../NearbyViewModel.kt} | 63 +++++++++---------- .../ui/nearby/PermissionRequiredScreen.kt | 51 +++++++++++++++ app/src/main/res/values-es/strings.xml | 4 ++ app/src/main/res/values-gl/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ gradle/libs.versions.toml | 2 + 10 files changed, 147 insertions(+), 73 deletions(-) rename app/src/main/java/io/github/balexei/vitrasaparada/ui/{MainViewModel.kt => nearby/NearbyViewModel.kt} (74%) create mode 100644 app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/PermissionRequiredScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f8747a..15c093b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -98,6 +98,7 @@ dependencies { implementation(libs.androidx.room.ktx) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.material.icons.core) + implementation(libs.accompanist.permissions) implementation(libs.timber) implementation(libs.play.services.location) annotationProcessor(libs.androidx.room.compiler) diff --git a/app/src/main/java/io/github/balexei/vitrasaparada/ui/MainActivity.kt b/app/src/main/java/io/github/balexei/vitrasaparada/ui/MainActivity.kt index b54a8a6..aa1c2a6 100644 --- a/app/src/main/java/io/github/balexei/vitrasaparada/ui/MainActivity.kt +++ b/app/src/main/java/io/github/balexei/vitrasaparada/ui/MainActivity.kt @@ -1,41 +1,22 @@ package io.github.balexei.vitrasaparada.ui -import android.Manifest import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import io.github.balexei.vitrasaparada.ui.components.BottomNavigationBar import io.github.balexei.vitrasaparada.ui.theme.VitrasaParadaTheme -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import timber.log.Timber class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels { MainViewModel.Factory } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.requestLocationPermissionEvent.collectLatest { - Timber.d("Received request to open permission dialog") - requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } - } - } setContent { VitrasaParadaTheme { val navController = rememberNavController() @@ -59,21 +40,9 @@ class MainActivity : ComponentActivity() { NavGraph( modifier = Modifier.padding(innerPadding), navController = navController, - viewModel = viewModel ) } } } } - - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (isGranted) { - Timber.d("Location permission granted") - viewModel.onLocationPermissionGranted() - } else { - Timber.d("Location permission denied") - } - } } diff --git a/app/src/main/java/io/github/balexei/vitrasaparada/ui/NavGraph.kt b/app/src/main/java/io/github/balexei/vitrasaparada/ui/NavGraph.kt index a0e491f..30656bd 100644 --- a/app/src/main/java/io/github/balexei/vitrasaparada/ui/NavGraph.kt +++ b/app/src/main/java/io/github/balexei/vitrasaparada/ui/NavGraph.kt @@ -12,12 +12,12 @@ import io.github.balexei.vitrasaparada.ui.all.AllRoute import io.github.balexei.vitrasaparada.ui.all.AllViewModel import io.github.balexei.vitrasaparada.ui.favourites.FavouritesRoute import io.github.balexei.vitrasaparada.ui.favourites.FavouritesViewModel +import io.github.balexei.vitrasaparada.ui.nearby.NearbyViewModel import io.github.balexei.vitrasaparada.ui.nearby.NearbyRoute @Composable fun NavGraph( modifier: Modifier = Modifier, - viewModel: MainViewModel, navController: NavHostController = rememberNavController(), startDestination: String = Destinations.FAVOURITES ) { @@ -34,7 +34,7 @@ fun NavGraph( composable( route = Destinations.NEARBY ) { navBackStackEntry -> - NearbyRoute(viewModel) + NearbyRoute(viewModel(factory = NearbyViewModel.Factory)) } composable( route = Destinations.ALL diff --git a/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/NearbyRoute.kt b/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/NearbyRoute.kt index d22b505..5ddbc97 100644 --- a/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/NearbyRoute.kt +++ b/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/NearbyRoute.kt @@ -1,14 +1,58 @@ package io.github.balexei.vitrasaparada.ui.nearby +import android.Manifest import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import io.github.balexei.vitrasaparada.ui.MainViewModel import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import io.github.balexei.vitrasaparada.R - +@OptIn(ExperimentalPermissionsApi::class) @Composable -fun NearbyRoute(viewModel: MainViewModel) { - val currentLocation by viewModel.currentLocation.collectAsState() - val nearbyStops by viewModel.nearbyStops.collectAsState() - NearbyScreen(currentLocation = currentLocation, stops = nearbyStops, setFavourite = viewModel::setFavourite,) +fun NearbyRoute(viewModel: NearbyViewModel) { + val permissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) + LaunchedEffect(Unit) { + if (!permissionState.status.isGranted) { + permissionState.launchPermissionRequest() + } + } + DisposableEffect(permissionState.status.isGranted) { + if (permissionState.status.isGranted) { + viewModel.startLocationUpdates() + } + onDispose { + viewModel.stopLocationUpdates() + } + } + + when { + permissionState.status.isGranted -> { + val currentLocation by viewModel.currentLocation.collectAsState() + val nearbyStops by viewModel.nearbyStops.collectAsState() + NearbyScreen( + currentLocation = currentLocation, + stops = nearbyStops, + setFavourite = viewModel::setFavourite, + ) + } + + permissionState.status.shouldShowRationale -> { + PermissionRequiredScreen(message = stringResource(id = R.string.location_rationale_message), + onRequestPermission = { permissionState.launchPermissionRequest() }) + } + + else -> { + PermissionRequiredScreen( + message = stringResource(id = R.string.location_required_message), + onRequestPermission = { permissionState.launchPermissionRequest() }, + showSettingsButton = true + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/github/balexei/vitrasaparada/ui/MainViewModel.kt b/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/NearbyViewModel.kt similarity index 74% rename from app/src/main/java/io/github/balexei/vitrasaparada/ui/MainViewModel.kt rename to app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/NearbyViewModel.kt index cce9db3..71376d3 100644 --- a/app/src/main/java/io/github/balexei/vitrasaparada/ui/MainViewModel.kt +++ b/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/NearbyViewModel.kt @@ -1,4 +1,4 @@ -package io.github.balexei.vitrasaparada.ui +package io.github.balexei.vitrasaparada.ui.nearby import android.location.Location import androidx.lifecycle.SavedStateHandle @@ -15,22 +15,18 @@ import io.github.balexei.vitrasaparada.data.location.LocationRepository import io.github.balexei.vitrasaparada.data.model.BusStop import io.github.balexei.vitrasaparada.data.model.Position import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber -class MainViewModel( +class NearbyViewModel( private val busStopRepository: BusStopRepository, private val locationRepository: LocationRepository, private val savedStateHandle: SavedStateHandle @@ -39,15 +35,8 @@ class MainViewModel( attachLocationListeners() } - fun setFavourite(id: Int, value: Boolean) { - viewModelScope.launch { - Timber.d("Setting stop id $id favourite to $value") - busStopRepository.setFavorite(id, value) - } - } - private val _currentLocation = MutableStateFlow(null) - val currentLocation: StateFlow = _currentLocation + val currentLocation: StateFlow = _currentLocation.asStateFlow() val nearbyStops: StateFlow> = currentLocation.combine(busStopRepository.getBusStopsStream()) { location, stops -> @@ -55,10 +44,25 @@ class MainViewModel( filterNearbyStops(stops, it) } ?: emptyList() }.flowOn(Dispatchers.Default).stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyList() - ) + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + fun setFavourite(id: Int, value: Boolean) { + viewModelScope.launch { + Timber.d("Setting stop id $id favourite to $value") + busStopRepository.setFavorite(id, value) + } + } + + fun startLocationUpdates() { + locationRepository.startLocationUpdates() + } + + fun stopLocationUpdates() { + locationRepository.stopLocationUpdates() + } private fun filterNearbyStops( stops: List, @@ -81,21 +85,12 @@ class MainViewModel( viewModelScope.launch { locationRepository.getLocationFlow() .map { it?.let { Position(it.latitude, it.longitude) } } - .collect { _currentLocation.value = it } + .collect { position -> + if (position != null) { + _currentLocation.value = position + } + } } - viewModelScope.launch { - _currentLocation.subscriptionCount.map { count -> count > 0 }.distinctUntilChanged() - .onEach { isActive -> - if (isActive) locationRepository.startLocationUpdates() else locationRepository.stopLocationUpdates() - }.launchIn(viewModelScope) - } - } - - private val _requestLocationPermissionEvent = MutableSharedFlow() - val requestLocationPermissionEvent = _requestLocationPermissionEvent.asSharedFlow() - - fun onLocationPermissionGranted() { - locationRepository.startLocationUpdates() } companion object { @@ -103,7 +98,7 @@ class MainViewModel( initializer { val savedStateHandle = createSavedStateHandle() val application = this[APPLICATION_KEY] as VitrasaParada - MainViewModel( + NearbyViewModel( busStopRepository = application.busStopRepository, locationRepository = application.locationRepository, savedStateHandle = savedStateHandle diff --git a/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/PermissionRequiredScreen.kt b/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/PermissionRequiredScreen.kt new file mode 100644 index 0000000..38210e2 --- /dev/null +++ b/app/src/main/java/io/github/balexei/vitrasaparada/ui/nearby/PermissionRequiredScreen.kt @@ -0,0 +1,51 @@ +package io.github.balexei.vitrasaparada.ui.nearby + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.balexei.vitrasaparada.R + +@Composable +fun PermissionRequiredScreen( + message: String, + onRequestPermission: () -> Unit, + showSettingsButton: Boolean = false +) { + val context = LocalContext.current + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(message) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRequestPermission) { + Text(stringResource(id = R.string.grant_permission)) + } + if (showSettingsButton) { + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + }) { + Text(stringResource(id = R.string.open_settings)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c5ef495..81a6c12 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -9,5 +9,9 @@ Las paradas marcadas como favoritas aparecerán aquí. Mostrando paradas en un radio de %dm LAT: %1$.5f LON: %2$.5f + Se requiere permiso de ubicación para mostrar las paradas de autobús cercanas. + Conceder permiso + La aplicación necesita acceder a la ubicación para mostrar paradas de autobús cercanas. Por favor, concede este permiso. + Abrir configuración Fuente de los datos: Ayuntamiento de Vigo \ No newline at end of file diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index e6fe2f0..5b6377d 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -9,5 +9,9 @@ As paradas marcadas como favoritas aparecerán aquí. Mostrando paradas nun radio de %dm LAT: %1$.5f LON: %2$.5f + É necesario o permiso de localización para mostrar as paradas de autobús próximas. + Conceder permiso + A aplicación precisa acceder á ubicación para amosar as paradas de autobús próximas. Por favor, concede este permiso. + Abrir configuración Fonte de información: Concello de Vigo \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 383c6d2..9fde942 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,10 @@ The stops marked as Favourite will appear here. Showing stops in a %dm radius LAT: %1$.5f LON: %2$.5f + Location permission is required to show nearby bus stops. + Grant Permission + The application needs to access your location to show nearby bus stops. Please allow this permission. + Open Settings Information source: Vigo City Council https://datos.vigo.org \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2b7c60..33047d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +accompanist = "0.36.0" agp = "8.7.2" kotlin = "2.0.21" coreKtx = "1.15.0" @@ -42,6 +43,7 @@ coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versi androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidxRoom" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidxRoom" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidxRoom" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } moshi-core = { group = "com.squareup.moshi", name = "moshi", version.ref = "moshi" } moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }