Skip to content

Commit

Permalink
refactor: create specific viewmodel for 'nearby' screen and improve p…
Browse files Browse the repository at this point in the history
…ermission handling
  • Loading branch information
balexei committed Nov 21, 2024
1 parent 77961a7 commit 46f6142
Show file tree
Hide file tree
Showing 10 changed files with 147 additions and 73 deletions.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand All @@ -34,7 +34,7 @@ fun NavGraph(
composable(
route = Destinations.NEARBY
) { navBackStackEntry ->
NearbyRoute(viewModel)
NearbyRoute(viewModel(factory = NearbyViewModel.Factory))
}
composable(
route = Destinations.ALL
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -39,26 +35,34 @@ 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<Position?>(null)
val currentLocation: StateFlow<Position?> = _currentLocation
val currentLocation: StateFlow<Position?> = _currentLocation.asStateFlow()

val nearbyStops: StateFlow<List<BusStop>> =
currentLocation.combine(busStopRepository.getBusStopsStream()) { location, stops ->
location?.let {
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<BusStop>,
Expand All @@ -81,29 +85,20 @@ 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<Unit>()
val requestLocationPermissionEvent = _requestLocationPermissionEvent.asSharedFlow()

fun onLocationPermissionGranted() {
locationRepository.startLocationUpdates()
}

companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val savedStateHandle = createSavedStateHandle()
val application = this[APPLICATION_KEY] as VitrasaParada
MainViewModel(
NearbyViewModel(
busStopRepository = application.busStopRepository,
locationRepository = application.locationRepository,
savedStateHandle = savedStateHandle
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
4 changes: 4 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@
<string name="no_favourites_hint">Las paradas marcadas como favoritas aparecerán aquí.</string>
<string name="showing_stops_within_radius">Mostrando paradas en un radio de %dm</string>
<string name="lat_lon">LAT: %1$.5f LON: %2$.5f</string>
<string name="location_required_message">Se requiere permiso de ubicación para mostrar las paradas de autobús cercanas.</string>
<string name="grant_permission">Conceder permiso</string>
<string name="location_rationale_message">La aplicación necesita acceder a la ubicación para mostrar paradas de autobús cercanas. Por favor, concede este permiso.</string>
<string name="open_settings">Abrir configuración</string>
<string name="data_source_city_council">Fuente de los datos: Ayuntamiento de Vigo</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values-gl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@
<string name="no_favourites_hint">As paradas marcadas como favoritas aparecerán aquí.</string>
<string name="showing_stops_within_radius">Mostrando paradas nun radio de %dm</string>
<string name="lat_lon">LAT: %1$.5f LON: %2$.5f</string>
<string name="location_required_message">É necesario o permiso de localización para mostrar as paradas de autobús próximas.</string>
<string name="grant_permission">Conceder permiso</string>
<string name="location_rationale_message">A aplicación precisa acceder á ubicación para amosar as paradas de autobús próximas. Por favor, concede este permiso.</string>
<string name="open_settings">Abrir configuración</string>
<string name="data_source_city_council">Fonte de información: Concello de Vigo</string>
</resources>
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
<string name="no_favourites_hint">The stops marked as Favourite will appear here.</string>
<string name="showing_stops_within_radius">Showing stops in a %dm radius</string>
<string name="lat_lon">LAT: %1$.5f LON: %2$.5f</string>
<string name="location_required_message">Location permission is required to show nearby bus stops.</string>
<string name="grant_permission">Grant Permission</string>
<string name="location_rationale_message">The application needs to access your location to show nearby bus stops. Please allow this permission.</string>
<string name="open_settings">Open Settings</string>
<string name="data_source_city_council">Information source: Vigo City Council</string>
<string name="data_source_link" translatable="false">https://datos.vigo.org</string>
</resources>
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[versions]
accompanist = "0.36.0"
agp = "8.7.2"
kotlin = "2.0.21"
coreKtx = "1.15.0"
Expand Down Expand Up @@ -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" }
Expand Down

0 comments on commit 46f6142

Please sign in to comment.