From 0c3bed6213760983e0713cdc78142c21a5da5eeb Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 23 Jul 2026 12:51:26 +0200 Subject: [PATCH] =?UTF-8?q?Dostosuj=20lokalizacj=C4=99=20kursu=20do=20usta?= =?UTF-8?q?wie=C5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kierowca/data/AppPreferencesStore.kt | 10 + .../kierowca/data/model/DriverModels.kt | 1 + .../tracking/ActiveRouteTrackingService.kt | 58 ++- .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 447 +++++++++++++++--- .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 36 ++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 99 +++- .../kierowca/ui/PhotoLocationProvider.kt | 94 ++++ .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 23 + 8 files changed, 691 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/ui/PhotoLocationProvider.kt diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt b/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt index 80f49fb..0aee8cb 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt @@ -20,6 +20,7 @@ class AppPreferencesStore( private val themeModeKey = stringPreferencesKey("theme_mode") private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled") + private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled") val themeMode: Flow = dataStore.data .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } @@ -27,6 +28,9 @@ class AppPreferencesStore( val routeProgressNotificationEnabled: Flow = dataStore.data .map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true } + val routeLocationEnabled: Flow = dataStore.data + .map { preferences -> preferences[routeLocationEnabledKey] ?: false } + suspend fun setThemeMode(themeMode: AppThemeMode) { dataStore.edit { preferences -> preferences[themeModeKey] = themeMode.storedValue @@ -38,4 +42,10 @@ class AppPreferencesStore( preferences[routeProgressNotificationEnabledKey] = enabled } } + + suspend fun setRouteLocationEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[routeLocationEnabledKey] = enabled + } + } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index 8181a89..988c17f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -72,6 +72,7 @@ data class DriverAppSettingsDto( val allowGalleryUploads: Boolean?, val allowRouteCompletion: Boolean?, val requirePreciseLocationForPhotos: Boolean?, + val routeLocationMode: String? = null, val loadingPhotoRequirement: String? = null, val loadingWeightRequirement: String? = null, val unloadingPhotoRequirement: String? = null, diff --git a/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt b/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt index 859f3b3..fcf92a6 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt @@ -54,6 +54,11 @@ class ActiveRouteTrackingService : Service(), LocationListener { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action != ACTION_STOP && !hasLocationPermission()) { + stopSelf() + return START_NOT_STICKY + } + when (intent?.action) { ACTION_STOP -> { val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) @@ -69,7 +74,9 @@ class ActiveRouteTrackingService : Service(), LocationListener { routeId = id arrived = true runCatching { locationManager.removeUpdates(this) } - startForegroundCompat(buildNotification()) + if (!startForegroundCompat(buildNotification())) { + return START_NOT_STICKY + } } return START_NOT_STICKY } @@ -97,7 +104,9 @@ class ActiveRouteTrackingService : Service(), LocationListener { ?: totalDistanceMeters progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled if (routeChanged) arrived = false - startForegroundCompat(buildNotification()) + if (!startForegroundCompat(buildNotification())) { + return START_NOT_STICKY + } if (!arrived) requestLocationUpdates() return START_NOT_STICKY } @@ -113,9 +122,9 @@ class ActiveRouteTrackingService : Service(), LocationListener { if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { arrived = true runCatching { locationManager.removeUpdates(this) } - startForegroundCompat(buildNotification()) + if (!startForegroundCompat(buildNotification())) return } else { - startForegroundCompat(buildNotification()) + if (!startForegroundCompat(buildNotification())) return } val (battery, charging) = batterySnapshot() @@ -204,11 +213,25 @@ class ActiveRouteTrackingService : Service(), LocationListener { return builder.build() } - private fun startForegroundCompat(notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) - } else { - startForeground(NOTIFICATION_ID, notification) + private fun startForegroundCompat(notification: Notification): Boolean { + if (!hasLocationPermission()) { + stopSelf() + return false + } + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + } else { + startForeground(NOTIFICATION_ID, notification) + } + true + } catch (_: SecurityException) { + stopSelf() + false + } catch (_: IllegalStateException) { + stopSelf() + false } } @@ -220,7 +243,7 @@ class ActiveRouteTrackingService : Service(), LocationListener { "Aktywny kurs", NotificationManager.IMPORTANCE_LOW, ).apply { - description = "Postęp aktywnego kursu i lokalizacja w tle" + description = "Informacje o aktywnym kursie" } manager.createNotificationChannel(channel) } @@ -299,6 +322,8 @@ class ActiveRouteTrackingService : Service(), LocationListener { private const val ACTIVE_ROUTE_ID = "active_route_id" fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) { + if (!hasLocationPermission(context)) return + val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { action = ACTION_START putExtra(EXTRA_ROUTE_ID, route.id) @@ -309,7 +334,7 @@ class ActiveRouteTrackingService : Service(), LocationListener { route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) } route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) } } - ContextCompat.startForegroundService(context, intent) + runCatching { ContextCompat.startForegroundService(context, intent) } } fun stop(context: Context, routeId: String? = null) { @@ -325,11 +350,20 @@ class ActiveRouteTrackingService : Service(), LocationListener { } fun markArrived(context: Context, routeId: String) { + if (!hasLocationPermission(context)) return + val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE) + .getString(ACTIVE_ROUTE_ID, null) + if (activeRouteId != routeId) return + val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { action = ACTION_MARK_ARRIVED putExtra(EXTRA_ROUTE_ID, routeId) } - ContextCompat.startForegroundService(context, intent) + runCatching { ContextCompat.startForegroundService(context, intent) } } + + private fun hasLocationPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 814da06..e0c37a8 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -8,8 +8,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager -import android.location.Location -import android.location.LocationManager import android.net.Uri import android.os.Build import android.provider.Settings @@ -68,6 +66,7 @@ import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.LocationOff import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocalShipping import androidx.compose.material.icons.outlined.Navigation @@ -107,6 +106,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -148,6 +148,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import java.util.Locale import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.BuildConfig @@ -173,6 +174,12 @@ import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.TppTheme import pl.firmatpp.kierowca.update.PlayAppUpdateState +private enum class RouteLocationPermissionPurpose { + StartRequiredRoute, + EnableOptionalTracking, + RestoreRequiredTracking, +} + @Composable fun DriverApp( viewModel: DriverViewModel, @@ -203,6 +210,23 @@ fun DriverApp( val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var appInForeground by remember { mutableStateOf(true) } + val activeTrackingRouteId = state.displaySelectedRoute + ?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" } + ?.id + ?: state.displayRoutes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }?.id + var locationPermissionGranted by remember(context) { + mutableStateOf(hasLocationPermission(context)) + } + var showLocationPermissionDialog by remember { mutableStateOf(false) } + var showLocationPermissionDeniedDialog by remember { mutableStateOf(false) } + var locationPermissionPurpose by remember { + mutableStateOf(null) + } + val warnAboutRequiredLocation = shouldWarnAboutRequiredRouteLocation( + mode = state.routeLocationMode, + hasActiveRoute = activeTrackingRouteId != null, + hasLocationPermission = locationPermissionGranted, + ) val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> @@ -212,6 +236,75 @@ fun DriverApp( viewModel.markNotificationPermissionDenied() } } + val locationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + val granted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || + grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true || + hasLocationPermission(context) + locationPermissionGranted = granted + if (granted) { + showLocationPermissionDialog = false + showLocationPermissionDeniedDialog = false + if (locationPermissionPurpose == RouteLocationPermissionPurpose.EnableOptionalTracking) { + viewModel.setRouteLocationEnabled(true) + } + viewModel.onLocationPermissionGranted() + if (locationPermissionPurpose == RouteLocationPermissionPurpose.StartRequiredRoute) { + viewModel.openStartRoute() + } + } else { + showLocationPermissionDeniedDialog = true + } + locationPermissionPurpose = null + } + val dismissLocationPermissionDialog = { + showLocationPermissionDialog = false + locationPermissionPurpose = null + } + val requestLocationPermission = { + showLocationPermissionDialog = false + showLocationPermissionDeniedDialog = false + if (locationPermissionPurpose == RouteLocationPermissionPurpose.EnableOptionalTracking) { + viewModel.setRouteLocationEnabled(true) + } + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + val requestLocationForRequiredRouteStart = { + if (locationPermissionGranted) { + viewModel.openStartRoute() + } else { + locationPermissionPurpose = RouteLocationPermissionPurpose.StartRequiredRoute + showLocationPermissionDialog = true + } + } + val changeOptionalRouteLocation = { enabled: Boolean -> + if (!enabled) { + viewModel.setRouteLocationEnabled(false) + } else if (locationPermissionGranted) { + viewModel.setRouteLocationEnabled(true) + viewModel.onLocationPermissionGranted() + } else { + locationPermissionPurpose = RouteLocationPermissionPurpose.EnableOptionalTracking + showLocationPermissionDialog = true + } + } + val requestRequiredRouteLocation = { + locationPermissionPurpose = RouteLocationPermissionPurpose.RestoreRequiredTracking + showLocationPermissionDialog = true + } + val openStartRoute = { + if (routeStartRequiresLocationPermission(state.routeLocationMode, locationPermissionGranted)) { + requestLocationForRequiredRouteStart() + } else { + viewModel.openStartRoute() + } + } BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) { viewModel.back() @@ -276,6 +369,20 @@ fun DriverApp( viewModel.markNotificationPermissionBlocked() } } + LaunchedEffect( + appInForeground, + activeTrackingRouteId, + state.routeLocationMode, + state.routeLocationEnabled, + ) { + if (!appInForeground) return@LaunchedEffect + + val granted = hasLocationPermission(context) + if (granted && activeTrackingRouteId != null) { + viewModel.onLocationPermissionGranted() + } + locationPermissionGranted = granted + } Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) { when (state.screen) { @@ -322,6 +429,9 @@ fun DriverApp( }, onOpenNotificationSettings = { openAppNotificationSettings(context) }, onThemeModeChanged = viewModel::setThemeMode, + locationPermissionGranted = locationPermissionGranted, + onRouteLocationChanged = changeOptionalRouteLocation, + onRequestRequiredLocation = requestRequiredRouteLocation, onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled, onDiagnostics = viewModel::openDiagnostics, ) @@ -331,13 +441,14 @@ fun DriverApp( onRefresh = viewModel::refreshDiagnostics, ) DriverScreen.Detail -> DetailScreen( - state, - viewModel::back, - viewModel::openPhoto, - viewModel::refreshSelectedRoute, - viewModel::openStartRoute, - viewModel::openFinishRoute, - viewModel::correctFailedRouteAction, + state = state, + locationPermissionGranted = locationPermissionGranted, + onBack = viewModel::back, + onPhoto = viewModel::openPhoto, + onRefresh = viewModel::refreshSelectedRoute, + onStartRoute = openStartRoute, + onFinishRoute = viewModel::openFinishRoute, + onCorrectRouteAction = viewModel::correctFailedRouteAction, ) DriverScreen.StartRoute -> RouteStageScreen( state = state, @@ -436,18 +547,124 @@ fun DriverApp( } } - if (updateUi != null && !updateUi.required) { - AppUpdateBanner( - update = updateUi, - onUpdate = runUpdate, + if (warnAboutRequiredLocation || updateUi != null && !updateUi.required) { + Column( modifier = Modifier .align(Alignment.TopCenter) .statusBarsPadding() .padding(horizontal = 12.dp, vertical = 8.dp), - ) + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (warnAboutRequiredLocation) { + LocationPermissionWarningBanner( + onResolve = requestRequiredRouteLocation, + ) + } + if (updateUi != null && !updateUi.required) { + AppUpdateBanner( + update = updateUi, + onUpdate = runUpdate, + ) + } + } } } + if (showLocationPermissionDialog && updateUi?.required != true) { + val required = locationPermissionPurpose != RouteLocationPermissionPurpose.EnableOptionalTracking + AlertDialog( + onDismissRequest = dismissLocationPermissionDialog, + icon = { + Icon( + Icons.Outlined.LocationOn, + contentDescription = null, + tint = TppTheme.colors.forest, + ) + }, + title = { + Text( + if (required) "Lokalizacja jest wymagana" else "Lokalizacja podczas kursu", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text( + if (required) { + if (locationPermissionPurpose == RouteLocationPermissionPurpose.StartRequiredRoute) { + "Aby rozpocząć kurs, zezwól aplikacji na dostęp do lokalizacji." + } else { + "Lokalizacja jest wymagana. Zezwól aplikacji na dostęp w ustawieniach systemowych." + } + } else { + "Włącz lokalizację dla aktywnego kursu. Android poprosi teraz o zgodę systemową." + }, + color = TppTheme.colors.muted, + ) + }, + confirmButton = { + Button( + onClick = requestLocationPermission, + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + ) { + Text("Zezwól", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = dismissLocationPermissionDialog) { + Text("Anuluj", color = TppTheme.colors.muted) + } + }, + ) + } + + if (showLocationPermissionDeniedDialog && updateUi?.required != true) { + val required = state.routeLocationMode == RouteLocationMode.Required + AlertDialog( + onDismissRequest = { showLocationPermissionDeniedDialog = false }, + icon = { + Icon( + Icons.Outlined.LocationOff, + contentDescription = null, + tint = TppTheme.colors.error, + ) + }, + title = { + Text( + if (required) "Lokalizacja jest wymagana" else "Brak dostępu do lokalizacji", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text( + if (required) { + "Bez zgody na lokalizację nie można rozpocząć kursu. Włącz ją w ustawieniach aplikacji." + } else { + "Opcja nie została włączona. Możesz zezwolić na lokalizację później w ustawieniach aplikacji." + }, + color = TppTheme.colors.muted, + ) + }, + confirmButton = { + Button( + onClick = { + showLocationPermissionDeniedDialog = false + openAppSettings(context) + }, + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + ) { + Text("Otwórz ustawienia", color = Color.White, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { showLocationPermissionDeniedDialog = false }) { + Text("Anuluj", color = TppTheme.colors.muted) + } + }, + ) + } + if (updateUi?.required == true) { AlertDialog( onDismissRequest = {}, @@ -938,6 +1155,7 @@ private fun RoutesScreen( onDismissLiveUpdate: () -> Unit, ) { val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() var cameraUri by remember { mutableStateOf(null) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> @@ -950,7 +1168,9 @@ private fun RoutesScreen( hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), ) ) { - onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context)) + coroutineScope.launch { + onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context)) + } } cameraUri = null } @@ -2412,6 +2632,9 @@ private fun ProfileScreen( onNewRouteNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, onThemeModeChanged: (AppThemeMode) -> Unit, + locationPermissionGranted: Boolean, + onRouteLocationChanged: (Boolean) -> Unit, + onRequestRequiredLocation: () -> Unit, onRouteProgressNotificationChanged: (Boolean) -> Unit, onDiagnostics: () -> Unit, ) { @@ -2515,30 +2738,99 @@ private fun ProfileScreen( Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold) } } - HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, + if (state.routeLocationMode != RouteLocationMode.Disabled) { + HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) + if (state.routeLocationMode == RouteLocationMode.Optional) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Lokalizacja podczas kursu", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = TppTheme.colors.ink, + ) + Text( + if (state.routeLocationEnabled && locationPermissionGranted) { + "Włączona dla aktywnego kursu." + } else { + "Wyłączona. Możesz ją włączyć i udzielić zgody systemowej." + }, + style = MaterialTheme.typography.bodySmall, + color = TppTheme.colors.muted, + ) + } + Switch( + checked = state.routeLocationEnabled && locationPermissionGranted, + onCheckedChange = onRouteLocationChanged, + enabled = !state.loading, + ) + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "Lokalizacja podczas kursu", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = TppTheme.colors.ink, + ) + Text( + if (locationPermissionGranted) { + "Lokalizacja jest wymagana i ma dostęp systemowy." + } else { + "Lokalizacja jest wymagana. Zezwól na dostęp przed rozpoczęciem kursu." + }, + style = MaterialTheme.typography.bodySmall, + color = if (locationPermissionGranted) TppTheme.colors.muted else TppTheme.colors.error, + ) + if (!locationPermissionGranted) { + Button( + onClick = onRequestRequiredLocation, + modifier = Modifier.fillMaxWidth().height(48.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy), + shape = MaterialTheme.shapes.small, + ) { + Text("Zezwól na lokalizację", fontWeight = FontWeight.Bold) + } + } + } + } + } + if ( + shouldTrackRouteLocation( + state.routeLocationMode, + state.routeLocationEnabled, + locationPermissionGranted, + ) ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - "Pasek postępu aktywnego kursu", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = TppTheme.colors.ink, - ) - Text( - if (state.routeProgressNotificationEnabled) "Powiadomienie pokazuje dystans do celu." else "Powiadomienie zostaje minimalne: Kurs aktywny.", - style = MaterialTheme.typography.bodySmall, - color = TppTheme.colors.muted, + HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Pasek postępu aktywnego kursu", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = TppTheme.colors.ink, + ) + Text( + if (state.routeProgressNotificationEnabled) "Powiadomienie pokazuje dystans do celu." else "Powiadomienie zostaje minimalne: Kurs aktywny.", + style = MaterialTheme.typography.bodySmall, + color = TppTheme.colors.muted, + ) + } + Switch( + checked = state.routeProgressNotificationEnabled, + onCheckedChange = onRouteProgressNotificationChanged, + enabled = !state.loading, ) } - Switch( - checked = state.routeProgressNotificationEnabled, - onCheckedChange = onRouteProgressNotificationChanged, - enabled = !state.loading, - ) } HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) Row( @@ -2886,6 +3178,7 @@ private fun RouteStageScreen( onSubmit: () -> Unit, ) { val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() var cameraUri by remember { mutableStateOf(null) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) } @@ -2934,7 +3227,9 @@ private fun RouteStageScreen( hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), ) ) { - onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) + coroutineScope.launch { + onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) + } } cameraUri = null } @@ -3266,6 +3561,7 @@ private fun RouteStageSubmitBar( @Composable private fun DetailScreen( state: DriverUiState, + locationPermissionGranted: Boolean, onBack: () -> Unit, onPhoto: (RoutePhotoDto) -> Unit, onRefresh: () -> Unit, @@ -3302,6 +3598,11 @@ private fun DetailScreen( feedback = state.feedback, routeActions = projection.visibleActions, routeProgressNotificationEnabled = state.routeProgressNotificationEnabled, + routeLocationActive = shouldTrackRouteLocation( + state.routeLocationMode, + state.routeLocationEnabled, + locationPermissionGranted, + ), loadingPhotoRequirement = state.loadingPhotoRequirement, loadingWeightRequirement = state.loadingWeightRequirement, onRefresh = onRefresh, @@ -3366,6 +3667,7 @@ private fun RouteLifecycleSection( feedback: String?, routeActions: List, routeProgressNotificationEnabled: Boolean, + routeLocationActive: Boolean, loadingPhotoRequirement: String, loadingWeightRequirement: String, onRefresh: () -> Unit, @@ -3411,7 +3713,7 @@ private fun RouteLifecycleSection( ) } - if (route.driverLifecycleStatus() == "W TRAKCIE" && !routeProgressNotificationEnabled) { + if (route.driverLifecycleStatus() == "W TRAKCIE" && routeLocationActive && !routeProgressNotificationEnabled) { Text( "Powiadomienie działa w trybie minimalnym.", color = TppTheme.colors.muted, @@ -4506,6 +4808,50 @@ private fun ErrorText(error: String?) { if (!error.isNullOrBlank()) Text(error, color = TppTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp)) } +@Composable +private fun LocationPermissionWarningBanner(onResolve: () -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer), + border = BorderStroke(1.dp, TppTheme.colors.warningOutline), + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.LocationOff, + contentDescription = null, + tint = Color(0xFF7A5100), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + "Lokalizacja jest wymagana", + color = Color(0xFF5F4200), + fontWeight = FontWeight.Bold, + ) + Text( + "Zezwól na lokalizację, aby korzystać z aktywnego kursu.", + color = Color(0xFF5F4200), + style = MaterialTheme.typography.bodySmall, + ) + } + TextButton(onClick = onResolve) { + Text( + "Zezwól", + color = TppTheme.colors.forest, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + @Composable private fun OfflineStaleBanner(state: DriverUiState) { val syncLabel = state.lastServerSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji" @@ -4591,8 +4937,8 @@ private fun cameraCapturePermissions(context: Context, requirePreciseLocation: B } }.toTypedArray() -private fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata { - val location = bestLastKnownLocation(context) +private suspend fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata { + val location = currentPhotoLocation(context) return PhotoUploadMetadata( takenAt = Instant.now().toString(), @@ -4617,27 +4963,6 @@ private fun openAppSettings(context: Context) { context.startActivity(intent) } -private fun bestLastKnownLocation(context: Context): Location? { - if (!hasLocationPermission(context)) return null - - val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null - val providers = listOf( - LocationManager.GPS_PROVIDER, - LocationManager.NETWORK_PROVIDER, - LocationManager.PASSIVE_PROVIDER, - ) - - return providers.mapNotNull { provider -> - runCatching { - if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) { - manager.getLastKnownLocation(provider) - } else { - null - } - }.getOrNull() - }.maxByOrNull { it.time } -} - @Composable private fun SmsUserConsentEffect(enabled: Boolean, requestId: Long, onCode: (Long, String) -> Unit) { val context = LocalContext.current diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 4602a93..55ebcee 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -40,6 +40,42 @@ data class AppUpdateBannerUi( val downloaded: Boolean, ) +object RouteLocationMode { + const val Disabled = "disabled" + const val Optional = "optional" + const val Required = "required" +} + +fun normalizeRouteLocationMode(value: String?): String = when (value) { + RouteLocationMode.Disabled, + RouteLocationMode.Required, + -> value + else -> RouteLocationMode.Optional +} + +fun shouldTrackRouteLocation( + mode: String, + optionalLocationEnabled: Boolean, + hasLocationPermission: Boolean, +): Boolean = hasLocationPermission && when (normalizeRouteLocationMode(mode)) { + RouteLocationMode.Disabled -> false + RouteLocationMode.Required -> true + else -> optionalLocationEnabled +} + +fun routeStartRequiresLocationPermission( + mode: String, + hasLocationPermission: Boolean, +): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && !hasLocationPermission + +fun shouldWarnAboutRequiredRouteLocation( + mode: String, + hasActiveRoute: Boolean, + hasLocationPermission: Boolean, +): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && + hasActiveRoute && + !hasLocationPermission + fun appUpdateBannerUi( currentVersionCode: Int, latestVersionCode: Int?, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 0cb8e95..b9f5e81 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -1,7 +1,10 @@ package pl.firmatpp.kierowca.ui +import android.Manifest import android.app.Application +import android.content.pm.PackageManager import android.net.Uri +import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.messaging.FirebaseMessaging @@ -81,6 +84,8 @@ data class DriverUiState( val allowGalleryUploads: Boolean = true, val allowRouteCompletion: Boolean = false, val requirePreciseLocationForPhotos: Boolean = false, + val routeLocationMode: String = RouteLocationMode.Optional, + val routeLocationEnabled: Boolean = false, val loadingPhotoRequirement: String = RouteStageRequirement.Required, val loadingWeightRequirement: String = RouteStageRequirement.Required, val unloadingPhotoRequirement: String = RouteStageRequirement.Required, @@ -241,6 +246,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(routeProgressNotificationEnabled = enabled) } } } + viewModelScope.launch { + appPreferencesStore.routeLocationEnabled.collect { enabled -> + _state.update { it.copy(routeLocationEnabled = enabled) } + reconcileTrackingServiceFromState() + } + } viewModelScope.launch { photoUploadOutbox.observeQueuedUploads().collect { uploads -> _state.update { it.copy(queuedPhotoUploads = uploads) } @@ -507,6 +518,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos ?: it.requirePreciseLocationForPhotos, + routeLocationMode = normalizeRouteLocationMode(settings?.routeLocationMode), loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement), loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), @@ -531,7 +543,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId) } observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) - if (_state.value.isOnline && !cached.stale && settings?.selectedDate == settings?.today) { + val trackingSnapshot = _state.value + if ( + !shouldTrackRouteLocation( + trackingSnapshot.routeLocationMode, + trackingSnapshot.routeLocationEnabled, + hasLocationPermission(), + ) + ) { + ActiveRouteTrackingService.stop(getApplication()) + } else if (trackingSnapshot.isOnline && !cached.stale && settings?.selectedDate == settings?.today) { reconcileTrackingService(response.routes.today, response.session.driver) } }.onFailure { throwable -> @@ -700,6 +721,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"), DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"), DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"), + DiagnosticEntry("Lokalizacja kursu", runtime.routeLocationMode), + DiagnosticEntry("Wybór kierowcy", if (runtime.routeLocationEnabled) "włączony" else "wyłączony"), DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement), DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement), DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement), @@ -796,6 +819,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + fun setRouteLocationEnabled(enabled: Boolean) { + if (_state.value.routeLocationMode != RouteLocationMode.Optional) return + + _state.update { it.copy(routeLocationEnabled = enabled, error = null) } + reconcileTrackingServiceFromState() + viewModelScope.launch { + runCatching { appPreferencesStore.setRouteLocationEnabled(enabled) } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + } + } + + fun onLocationPermissionGranted() { + val snapshot = _state.value + val driver = snapshot.driver ?: return + if (!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())) { + return + } + val activeRoute = snapshot.displaySelectedRoute + ?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" } + ?: snapshot.displayRoutes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" } + ?: return + + ActiveRouteTrackingService.start( + context = getApplication(), + route = activeRoute, + driver = driver, + progressEnabled = snapshot.routeProgressNotificationEnabled, + ) + } + fun openPhotoQueue() { _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } } @@ -1107,6 +1160,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openStartRoute() { val snapshot = _state.value val route = snapshot.selectedRoute ?: return + if (routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) { + _state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") } + return + } if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) { _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } return @@ -1222,6 +1279,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) private fun submitRouteStage(stage: String) { val snapshot = _state.value val route = snapshot.selectedRoute ?: return + if (stage == "loading" && routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) { + _state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") } + return + } val canSubmitStage = if (stage == "loading") { canStartRouteFromDriverApp(route, snapshot.selectedDate) } else { @@ -1333,7 +1394,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } - if (stage == "loading" && driver != null) { + if ( + stage == "loading" && + driver != null && + shouldTrackRouteLocation( + snapshot.routeLocationMode, + snapshot.routeLocationEnabled, + hasLocationPermission(), + ) + ) { ActiveRouteTrackingService.start( context = getApplication(), route = updatedRoute, @@ -1597,8 +1666,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } private fun reconcileTrackingService(routes: List, driver: DriverDto) { + val snapshot = _state.value val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" } - if (activeRoute == null) { + if ( + activeRoute == null || + !shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission()) + ) { ActiveRouteTrackingService.stop(getApplication()) } else { ActiveRouteTrackingService.start( @@ -1611,7 +1684,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { - if (route.driverLifecycleStatus() == "W TRAKCIE") { + val snapshot = _state.value + if ( + route.driverLifecycleStatus() == "W TRAKCIE" && + shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission()) + ) { if (driver != null) { ActiveRouteTrackingService.start( context = getApplication(), @@ -1625,6 +1702,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + private fun reconcileTrackingServiceFromState() { + val snapshot = _state.value + val driver = snapshot.driver + if (driver == null) { + ActiveRouteTrackingService.stop(getApplication()) + return + } + reconcileTrackingService(snapshot.displayRoutes, driver) + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + private fun observeRouteActions(routeId: String) { routeActionsJob?.cancel() routeActionsJob = viewModelScope.launch { diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/PhotoLocationProvider.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/PhotoLocationProvider.kt new file mode 100644 index 0000000..d1311c3 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/PhotoLocationProvider.kt @@ -0,0 +1,94 @@ +package pl.firmatpp.kierowca.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.CancellationSignal +import android.os.Looper +import androidx.core.content.ContextCompat +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull + +private const val PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS = 5_000L + +internal suspend fun currentPhotoLocation(context: Context): Location? { + if (!hasAnyLocationPermission(context)) return null + + val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null + val hasFineLocation = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION, + ) == PackageManager.PERMISSION_GRANTED + val providers = buildList { + if (hasFineLocation) add(LocationManager.GPS_PROVIDER) + add(LocationManager.NETWORK_PROVIDER) + }.filter { provider -> runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false) } + + for (provider in providers) { + val location = withTimeoutOrNull(PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS) { + manager.awaitSingleLocation(context, provider) + } + if (location != null) return location + } + + return bestLastKnownLocation(manager) +} + +private suspend fun LocationManager.awaitSingleLocation( + context: Context, + provider: String, +): Location? = suspendCancellableCoroutine { continuation -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val cancellationSignal = CancellationSignal() + continuation.invokeOnCancellation { cancellationSignal.cancel() } + runCatching { + getCurrentLocation(provider, cancellationSignal, context.mainExecutor) { location -> + if (continuation.isActive) continuation.resume(location) + } + }.onFailure { + if (continuation.isActive) continuation.resume(null) + } + return@suspendCancellableCoroutine + } + + val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + removeUpdates(this) + if (continuation.isActive) continuation.resume(location) + } + } + continuation.invokeOnCancellation { runCatching { removeUpdates(listener) } } + runCatching { + @Suppress("DEPRECATION") + requestSingleUpdate(provider, listener, Looper.getMainLooper()) + }.onFailure { + if (continuation.isActive) continuation.resume(null) + } +} + +private fun hasAnyLocationPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + +private fun bestLastKnownLocation(manager: LocationManager): Location? { + val providers = listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + ) + + return providers.mapNotNull { provider -> + runCatching { + if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) { + manager.getLastKnownLocation(provider) + } else { + null + } + }.getOrNull() + }.maxByOrNull { it.time } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index 6924c1d..bb7cfe3 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -16,6 +16,29 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType class DriverUiRulesTest { private val today = LocalDate.parse("2026-06-30") + @Test + fun disabledRouteLocationNeverStartsTracking() { + assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true)) + assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = false, hasLocationPermission = true)) + } + + @Test + fun optionalRouteLocationFollowsDriverChoiceAndPermission() { + assertTrue(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = true)) + assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = false, hasLocationPermission = true)) + assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = false)) + } + + @Test + fun requiredRouteLocationBlocksStartAndWarnsOnlyWhenNeeded() { + assertTrue(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = false)) + assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = true)) + assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Optional, hasLocationPermission = false)) + assertTrue(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = true, hasLocationPermission = false)) + assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = false, hasLocationPermission = false)) + assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Optional, hasActiveRoute = true, hasLocationPermission = false)) + } + @Test fun allowsPhotoActionsOnlyForTodayRouteDate() { assertTrue(canManageRoutePhotos("2026-06-30", today))