Dostosuj lokalizację kursu do ustawień

This commit is contained in:
admin
2026-07-23 12:51:26 +02:00
parent f1cb2d618d
commit 0c3bed6213
8 changed files with 691 additions and 77 deletions
@@ -20,6 +20,7 @@ class AppPreferencesStore(
private val themeModeKey = stringPreferencesKey("theme_mode") private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled") private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true } .map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
suspend fun setThemeMode(themeMode: AppThemeMode) { suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue preferences[themeModeKey] = themeMode.storedValue
@@ -38,4 +42,10 @@ class AppPreferencesStore(
preferences[routeProgressNotificationEnabledKey] = enabled preferences[routeProgressNotificationEnabledKey] = enabled
} }
} }
suspend fun setRouteLocationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeLocationEnabledKey] = enabled
}
}
} }
@@ -72,6 +72,7 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?, val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?, val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?, val requirePreciseLocationForPhotos: Boolean?,
val routeLocationMode: String? = null,
val loadingPhotoRequirement: String? = null, val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null, val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null, val unloadingPhotoRequirement: String? = null,
@@ -54,6 +54,11 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_STOP && !hasLocationPermission()) {
stopSelf()
return START_NOT_STICKY
}
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
@@ -69,7 +74,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
routeId = id routeId = id
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -97,7 +104,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?: totalDistanceMeters ?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
if (routeChanged) arrived = false if (routeChanged) arrived = false
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
if (!arrived) requestLocationUpdates() if (!arrived) requestLocationUpdates()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -113,9 +122,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} else { } else {
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} }
val (battery, charging) = batterySnapshot() val (battery, charging) = batterySnapshot()
@@ -204,11 +213,25 @@ class ActiveRouteTrackingService : Service(), LocationListener {
return builder.build() return builder.build()
} }
private fun startForegroundCompat(notification: Notification) { private fun startForegroundCompat(notification: Notification): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) { if (!hasLocationPermission()) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) stopSelf()
} else { return false
startForeground(NOTIFICATION_ID, notification) }
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", "Aktywny kurs",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "Postęp aktywnego kursu i lokalizacja w tle" description = "Informacje o aktywnym kursie"
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
@@ -299,6 +322,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val ACTIVE_ROUTE_ID = "active_route_id" private const val ACTIVE_ROUTE_ID = "active_route_id"
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) { fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
if (!hasLocationPermission(context)) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_START action = ACTION_START
putExtra(EXTRA_ROUTE_ID, route.id) putExtra(EXTRA_ROUTE_ID, route.id)
@@ -309,7 +334,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) } route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) } 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) { fun stop(context: Context, routeId: String? = null) {
@@ -325,11 +350,20 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
fun markArrived(context: Context, routeId: String) { 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 { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_MARK_ARRIVED action = ACTION_MARK_ARRIVED
putExtra(EXTRA_ROUTE_ID, routeId) 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
} }
} }
@@ -8,8 +8,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings 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.Factory
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info 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.LocationOn
import androidx.compose.material.icons.outlined.LocalShipping import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Navigation 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -148,6 +148,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.BuildConfig 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.ui.theme.TppTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState import pl.firmatpp.kierowca.update.PlayAppUpdateState
private enum class RouteLocationPermissionPurpose {
StartRequiredRoute,
EnableOptionalTracking,
RestoreRequiredTracking,
}
@Composable @Composable
fun DriverApp( fun DriverApp(
viewModel: DriverViewModel, viewModel: DriverViewModel,
@@ -203,6 +210,23 @@ fun DriverApp(
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current val context = LocalContext.current
var appInForeground by remember { mutableStateOf(true) } 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<RouteLocationPermissionPurpose?>(null)
}
val warnAboutRequiredLocation = shouldWarnAboutRequiredRouteLocation(
mode = state.routeLocationMode,
hasActiveRoute = activeTrackingRouteId != null,
hasLocationPermission = locationPermissionGranted,
)
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { granted -> ) { granted ->
@@ -212,6 +236,75 @@ fun DriverApp(
viewModel.markNotificationPermissionDenied() 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) { BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
viewModel.back() viewModel.back()
@@ -276,6 +369,20 @@ fun DriverApp(
viewModel.markNotificationPermissionBlocked() 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)) { Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) {
when (state.screen) { when (state.screen) {
@@ -322,6 +429,9 @@ fun DriverApp(
}, },
onOpenNotificationSettings = { openAppNotificationSettings(context) }, onOpenNotificationSettings = { openAppNotificationSettings(context) },
onThemeModeChanged = viewModel::setThemeMode, onThemeModeChanged = viewModel::setThemeMode,
locationPermissionGranted = locationPermissionGranted,
onRouteLocationChanged = changeOptionalRouteLocation,
onRequestRequiredLocation = requestRequiredRouteLocation,
onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled, onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled,
onDiagnostics = viewModel::openDiagnostics, onDiagnostics = viewModel::openDiagnostics,
) )
@@ -331,13 +441,14 @@ fun DriverApp(
onRefresh = viewModel::refreshDiagnostics, onRefresh = viewModel::refreshDiagnostics,
) )
DriverScreen.Detail -> DetailScreen( DriverScreen.Detail -> DetailScreen(
state, state = state,
viewModel::back, locationPermissionGranted = locationPermissionGranted,
viewModel::openPhoto, onBack = viewModel::back,
viewModel::refreshSelectedRoute, onPhoto = viewModel::openPhoto,
viewModel::openStartRoute, onRefresh = viewModel::refreshSelectedRoute,
viewModel::openFinishRoute, onStartRoute = openStartRoute,
viewModel::correctFailedRouteAction, onFinishRoute = viewModel::openFinishRoute,
onCorrectRouteAction = viewModel::correctFailedRouteAction,
) )
DriverScreen.StartRoute -> RouteStageScreen( DriverScreen.StartRoute -> RouteStageScreen(
state = state, state = state,
@@ -436,18 +547,124 @@ fun DriverApp(
} }
} }
if (updateUi != null && !updateUi.required) { if (warnAboutRequiredLocation || updateUi != null && !updateUi.required) {
AppUpdateBanner( Column(
update = updateUi,
onUpdate = runUpdate,
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp), .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) { if (updateUi?.required == true) {
AlertDialog( AlertDialog(
onDismissRequest = {}, onDismissRequest = {},
@@ -938,6 +1155,7 @@ private fun RoutesScreen(
onDismissLiveUpdate: () -> Unit, onDismissLiveUpdate: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -950,7 +1168,9 @@ private fun RoutesScreen(
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
) )
) { ) {
onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context)) coroutineScope.launch {
onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context))
}
} }
cameraUri = null cameraUri = null
} }
@@ -2412,6 +2632,9 @@ private fun ProfileScreen(
onNewRouteNotificationsChanged: (Boolean) -> Unit, onNewRouteNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onThemeModeChanged: (AppThemeMode) -> Unit, onThemeModeChanged: (AppThemeMode) -> Unit,
locationPermissionGranted: Boolean,
onRouteLocationChanged: (Boolean) -> Unit,
onRequestRequiredLocation: () -> Unit,
onRouteProgressNotificationChanged: (Boolean) -> Unit, onRouteProgressNotificationChanged: (Boolean) -> Unit,
onDiagnostics: () -> Unit, onDiagnostics: () -> Unit,
) { ) {
@@ -2515,30 +2738,99 @@ private fun ProfileScreen(
Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold) Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold)
} }
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) if (state.routeLocationMode != RouteLocationMode.Disabled) {
Row( HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
Modifier.fillMaxWidth(), if (state.routeLocationMode == RouteLocationMode.Optional) {
horizontalArrangement = Arrangement.spacedBy(14.dp), Row(
verticalAlignment = Alignment.CenterVertically, 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)) { HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
Text( Row(
"Pasek postępu aktywnego kursu", Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge, horizontalArrangement = Arrangement.spacedBy(14.dp),
fontWeight = FontWeight.SemiBold, verticalAlignment = Alignment.CenterVertically,
color = TppTheme.colors.ink, ) {
) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text( Text(
if (state.routeProgressNotificationEnabled) "Powiadomienie pokazuje dystans do celu." else "Powiadomienie zostaje minimalne: Kurs aktywny.", "Pasek postępu aktywnego kursu",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyLarge,
color = TppTheme.colors.muted, 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)) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
Row( Row(
@@ -2886,6 +3178,7 @@ private fun RouteStageScreen(
onSubmit: () -> Unit, onSubmit: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) }
@@ -2934,7 +3227,9 @@ private fun RouteStageScreen(
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
) )
) { ) {
onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) coroutineScope.launch {
onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
}
} }
cameraUri = null cameraUri = null
} }
@@ -3266,6 +3561,7 @@ private fun RouteStageSubmitBar(
@Composable @Composable
private fun DetailScreen( private fun DetailScreen(
state: DriverUiState, state: DriverUiState,
locationPermissionGranted: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
@@ -3302,6 +3598,11 @@ private fun DetailScreen(
feedback = state.feedback, feedback = state.feedback,
routeActions = projection.visibleActions, routeActions = projection.visibleActions,
routeProgressNotificationEnabled = state.routeProgressNotificationEnabled, routeProgressNotificationEnabled = state.routeProgressNotificationEnabled,
routeLocationActive = shouldTrackRouteLocation(
state.routeLocationMode,
state.routeLocationEnabled,
locationPermissionGranted,
),
loadingPhotoRequirement = state.loadingPhotoRequirement, loadingPhotoRequirement = state.loadingPhotoRequirement,
loadingWeightRequirement = state.loadingWeightRequirement, loadingWeightRequirement = state.loadingWeightRequirement,
onRefresh = onRefresh, onRefresh = onRefresh,
@@ -3366,6 +3667,7 @@ private fun RouteLifecycleSection(
feedback: String?, feedback: String?,
routeActions: List<RouteActionEntity>, routeActions: List<RouteActionEntity>,
routeProgressNotificationEnabled: Boolean, routeProgressNotificationEnabled: Boolean,
routeLocationActive: Boolean,
loadingPhotoRequirement: String, loadingPhotoRequirement: String,
loadingWeightRequirement: String, loadingWeightRequirement: String,
onRefresh: () -> Unit, onRefresh: () -> Unit,
@@ -3411,7 +3713,7 @@ private fun RouteLifecycleSection(
) )
} }
if (route.driverLifecycleStatus() == "W TRAKCIE" && !routeProgressNotificationEnabled) { if (route.driverLifecycleStatus() == "W TRAKCIE" && routeLocationActive && !routeProgressNotificationEnabled) {
Text( Text(
"Powiadomienie działa w trybie minimalnym.", "Powiadomienie działa w trybie minimalnym.",
color = TppTheme.colors.muted, 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)) 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 @Composable
private fun OfflineStaleBanner(state: DriverUiState) { private fun OfflineStaleBanner(state: DriverUiState) {
val syncLabel = state.lastServerSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji" val syncLabel = state.lastServerSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji"
@@ -4591,8 +4937,8 @@ private fun cameraCapturePermissions(context: Context, requirePreciseLocation: B
} }
}.toTypedArray() }.toTypedArray()
private fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata { private suspend fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata {
val location = bestLastKnownLocation(context) val location = currentPhotoLocation(context)
return PhotoUploadMetadata( return PhotoUploadMetadata(
takenAt = Instant.now().toString(), takenAt = Instant.now().toString(),
@@ -4617,27 +4963,6 @@ private fun openAppSettings(context: Context) {
context.startActivity(intent) 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 @Composable
private fun SmsUserConsentEffect(enabled: Boolean, requestId: Long, onCode: (Long, String) -> Unit) { private fun SmsUserConsentEffect(enabled: Boolean, requestId: Long, onCode: (Long, String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -40,6 +40,42 @@ data class AppUpdateBannerUi(
val downloaded: Boolean, 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( fun appUpdateBannerUi(
currentVersionCode: Int, currentVersionCode: Int,
latestVersionCode: Int?, latestVersionCode: Int?,
@@ -1,7 +1,10 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import android.Manifest
import android.app.Application import android.app.Application
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
@@ -81,6 +84,8 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true, val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false, val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false, val requirePreciseLocationForPhotos: Boolean = false,
val routeLocationMode: String = RouteLocationMode.Optional,
val routeLocationEnabled: Boolean = false,
val loadingPhotoRequirement: String = RouteStageRequirement.Required, val loadingPhotoRequirement: String = RouteStageRequirement.Required,
val loadingWeightRequirement: String = RouteStageRequirement.Required, val loadingWeightRequirement: String = RouteStageRequirement.Required,
val unloadingPhotoRequirement: String = RouteStageRequirement.Required, val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
@@ -241,6 +246,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeProgressNotificationEnabled = enabled) } _state.update { it.copy(routeProgressNotificationEnabled = enabled) }
} }
} }
viewModelScope.launch {
appPreferencesStore.routeLocationEnabled.collect { enabled ->
_state.update { it.copy(routeLocationEnabled = enabled) }
reconcileTrackingServiceFromState()
}
}
viewModelScope.launch { viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads -> photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) } _state.update { it.copy(queuedPhotoUploads = uploads) }
@@ -507,6 +518,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos, ?: it.requirePreciseLocationForPhotos,
routeLocationMode = normalizeRouteLocationMode(settings?.routeLocationMode),
loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement), loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement),
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
@@ -531,7 +543,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId) if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId)
} }
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) 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) reconcileTrackingService(response.routes.today, response.session.driver)
} }
}.onFailure { throwable -> }.onFailure { throwable ->
@@ -700,6 +721,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"), DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"),
DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"), DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"),
DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"), 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, zdjęcie", runtime.loadingPhotoRequirement),
DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement), DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement),
DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement), 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() { fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
} }
@@ -1107,6 +1160,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openStartRoute() { fun openStartRoute() {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return 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)) { if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return return
@@ -1222,6 +1279,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun submitRouteStage(stage: String) { private fun submitRouteStage(stage: String) {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return 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") { val canSubmitStage = if (stage == "loading") {
canStartRouteFromDriverApp(route, snapshot.selectedDate) canStartRouteFromDriverApp(route, snapshot.selectedDate)
} else { } 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( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
route = updatedRoute, route = updatedRoute,
@@ -1597,8 +1666,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) { private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val snapshot = _state.value
val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" } val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (activeRoute == null) { if (
activeRoute == null ||
!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
ActiveRouteTrackingService.stop(getApplication()) ActiveRouteTrackingService.stop(getApplication())
} else { } else {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
@@ -1611,7 +1684,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { 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) { if (driver != null) {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
context = getApplication(), 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) { private fun observeRouteActions(routeId: String) {
routeActionsJob?.cancel() routeActionsJob?.cancel()
routeActionsJob = viewModelScope.launch { routeActionsJob = viewModelScope.launch {
@@ -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 }
}
@@ -16,6 +16,29 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest { class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30") 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 @Test
fun allowsPhotoActionsOnlyForTodayRouteDate() { fun allowsPhotoActionsOnlyForTodayRouteDate() {
assertTrue(canManageRoutePhotos("2026-06-30", today)) assertTrue(canManageRoutePhotos("2026-06-30", today))