Compare commits

...
15 changed files with 1166 additions and 95 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 119
versionName = "1.0.66"
versionCode = 120
versionName = "1.0.67"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = 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
}
}
}
@@ -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,
@@ -14,6 +14,61 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'start'
AND createdAtEpochMillis < :beforeEpochMillis
ORDER BY createdAtEpochMillis DESC
LIMIT 1
""",
)
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis > :afterEpochMillis
AND status != 'CONFIRMED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND status = 'CONFIRMED'
AND createdAtEpochMillis > :afterEpochMillis
)
""",
)
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
@Query(
"""
UPDATE route_actions
SET status = 'CONFIRMED',
lastError = NULL,
updatedAtEpochMillis = :updatedAt
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis < :beforeEpochMillis
AND status != 'CONFIRMED'
""",
)
suspend fun confirmSupersededFinishes(
routeId: String,
beforeEpochMillis: Long,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity>
@@ -8,9 +8,11 @@ import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -31,6 +33,33 @@ class RouteActionWorker(
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure()
if (action.status == RouteActionStatus.Confirmed) return Result.success()
val startPrerequisite = if (action.action == RouteActionType.Finish) {
dao.latestStartBefore(action.routeId, action.createdAtEpochMillis)
} else {
null
}
when (routeActionPrerequisiteState(action, startPrerequisite)) {
RouteActionPrerequisiteState.Waiting -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Pending,
lastError = "Czekam na potwierdzenie rozpoczęcia kursu.",
attemptIncrement = 0,
)
return Result.retry()
}
RouteActionPrerequisiteState.Failed -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedConflict,
lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.",
attemptIncrement = 0,
)
return Result.failure()
}
RouteActionPrerequisiteState.Ready -> Unit
}
val photoClientRequestIds = photoClientRequestIds(action)
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
@@ -72,12 +101,25 @@ class RouteActionWorker(
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
if (action.action == RouteActionType.Start) {
val outbox = RouteActionOutbox(applicationContext)
dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis)
.forEach { outbox.enqueueWorker(it.clientActionId) }
}
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "route_action_worker",
@@ -89,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
"server_route_status" to serverRoute?.driverLifecycleStatus(),
),
)
val supersededByConfirmedFinish = action.action == RouteActionType.Finish &&
dao.hasConfirmedFinishAfter(action.routeId, action.createdAtEpochMillis)
if (serverRoute != null &&
(
routeActionMatchesServer(action, photoClientRequestIds, serverRoute) ||
routeActionWasSuperseded(action, supersededByConfirmedFinish, serverRoute)
)
) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Confirmed,
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate)
DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id)
return@getOrElse Result.success()
}
val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable
@@ -99,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus(
clientActionId = clientActionId,
status = status,
lastError = error.message,
lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0,
)
@@ -146,3 +210,84 @@ class RouteActionWorker(
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
}
}
internal fun routeActionMatchesServer(
action: RouteActionEntity,
photoClientRequestIds: List<String>,
serverRoute: DriverRouteDto,
): Boolean {
val lifecycleMatches = when (action.action) {
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
else -> false
}
if (!lifecycleMatches) return false
val serverWeight = when (action.action) {
RouteActionType.Start -> serverRoute.loadingWeight
RouteActionType.Finish -> serverRoute.unloadingWeight
else -> null
}
val serverNotes = when (action.action) {
RouteActionType.Start -> serverRoute.loadingNotes
RouteActionType.Finish -> serverRoute.unloadingNotes
else -> null
}
val weightMatches = when {
action.weight == null && serverWeight == null -> true
action.weight == null || serverWeight == null -> false
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
}
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
val serverPhotoRequestIds = serverRoute.photos
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
.toSet()
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
return weightMatches && notesMatch && photosMatch
}
internal fun routeActionWasSuperseded(
action: RouteActionEntity,
hasConfirmedFinishAfter: Boolean,
serverRoute: DriverRouteDto,
): Boolean =
action.action == RouteActionType.Finish &&
hasConfirmedFinishAfter &&
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
val operationalStatus = serverRoute.operationalStatus
?.takeIf(String::isNotBlank)
?.let { ", status operacyjny: $it" }
.orEmpty()
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
}
private fun String?.normalizedRouteActionNotes(): String? =
this?.trim()?.takeIf(String::isNotEmpty)
internal enum class RouteActionPrerequisiteState {
Ready,
Waiting,
Failed,
}
internal fun routeActionPrerequisiteState(
action: RouteActionEntity,
precedingStart: RouteActionEntity?,
): RouteActionPrerequisiteState {
if (action.action != RouteActionType.Finish || precedingStart == null) {
return RouteActionPrerequisiteState.Ready
}
return when (precedingStart.status) {
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
-> RouteActionPrerequisiteState.Failed
else -> RouteActionPrerequisiteState.Waiting
}
}
@@ -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
}
}
@@ -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<RouteLocationPermissionPurpose?>(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,15 @@ 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,
onRetryRouteAction = viewModel::retryConflictedRouteAction,
onCorrectRouteAction = viewModel::correctFailedRouteAction,
)
DriverScreen.StartRoute -> RouteStageScreen(
state = state,
@@ -436,18 +548,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 +1156,7 @@ private fun RoutesScreen(
onDismissLiveUpdate: () -> Unit,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -950,7 +1169,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 +2633,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 +2739,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 +3179,7 @@ private fun RouteStageScreen(
onSubmit: () -> Unit,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) }
@@ -2934,7 +3228,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,11 +3562,13 @@ private fun RouteStageSubmitBar(
@Composable
private fun DetailScreen(
state: DriverUiState,
locationPermissionGranted: Boolean,
onBack: () -> Unit,
onPhoto: (RoutePhotoDto) -> Unit,
onRefresh: () -> Unit,
onStartRoute: () -> Unit,
onFinishRoute: () -> Unit,
onRetryRouteAction: () -> Unit,
onCorrectRouteAction: () -> Unit,
) {
val context = LocalContext.current
@@ -3302,11 +3600,17 @@ 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,
onStart = onStartRoute,
onFinish = onFinishRoute,
onRetry = onRetryRouteAction,
onCorrect = onCorrectRouteAction,
)
}
@@ -3366,11 +3670,13 @@ private fun RouteLifecycleSection(
feedback: String?,
routeActions: List<RouteActionEntity>,
routeProgressNotificationEnabled: Boolean,
routeLocationActive: Boolean,
loadingPhotoRequirement: String,
loadingWeightRequirement: String,
onRefresh: () -> Unit,
onStart: () -> Unit,
onFinish: () -> Unit,
onRetry: () -> Unit,
onCorrect: () -> Unit,
) {
val steps = routeFlowSteps(route, routeActions, loadingPhotoRequirement, loadingWeightRequirement)
@@ -3406,12 +3712,20 @@ private fun RouteLifecycleSection(
container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel,
outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline,
color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted,
actionLabel = if (callout.canCorrect) "Popraw dane" else "Odśwież dane",
onAction = if (callout.canCorrect) onCorrect else onRefresh,
actionLabel = when {
callout.canCorrect -> "Popraw dane"
callout.isConflict -> "Ponów wysyłanie"
else -> "Odśwież dane"
},
onAction = when {
callout.canCorrect -> onCorrect
callout.isConflict -> onRetry
else -> onRefresh
},
)
}
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 +4820,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 +4949,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 +4975,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
@@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent,
)
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionLifecycleProjectionStatuses = setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
RouteActionStatus.Confirmed,
)
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
.filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses }
var projectedRoute = route
var loadingWeightPending = false
var unloadingWeightPending = false
@@ -39,25 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
when (action.action) {
RouteActionType.Start -> {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight,
loadingNotes = action.notes,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
)
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
trackingStatus = "active",
)
}
loadingWeightPending = action.status != RouteActionStatus.Confirmed
}
RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight,
unloadingNotes = action.notes,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
)
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
trackingStatus = "finished",
completedAt = action.occurredAt,
)
}
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
}
}
@@ -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?,
@@ -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
@@ -1211,6 +1268,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun retryConflictedRouteAction() {
val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions)
.distinctBy { it.clientActionId }
.filter { it.status == RouteActionStatus.FailedConflict }
.maxByOrNull { it.createdAtEpochMillis }
?: return
viewModelScope.launch {
runCatching {
routeActionOutbox.retry(action.clientActionId)
}
.onSuccess {
_state.update {
it.copy(
feedback = "Ponawiam wysyłanie zapisanych danych.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun submitStartRoute() {
submitRouteStage(stage = "loading")
}
@@ -1222,6 +1303,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 +1418,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 +1690,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private fun reconcileTrackingService(routes: List<DriverRouteDto>, 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 +1708,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 +1726,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 {
@@ -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 }
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteActionPrerequisiteTest {
@Test
fun finishWaitsUntilEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val pendingStart = action(RouteActionType.Start, RouteActionStatus.Syncing, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Waiting,
routeActionPrerequisiteState(finish, pendingStart),
)
}
@Test
fun finishRunsAfterEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val confirmedStart = action(RouteActionType.Start, RouteActionStatus.Confirmed, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, confirmedStart),
)
}
@Test
fun failedStartBlocksFinishInsteadOfSendingItOutOfOrder() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val failedStart = action(RouteActionType.Start, RouteActionStatus.FailedConflict, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Failed,
routeActionPrerequisiteState(finish, failedStart),
)
}
@Test
fun finishWithoutLocalStartCanUseServerLifecycleState() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, null),
)
}
private fun action(type: String, status: String, createdAt: Long): RouteActionEntity =
RouteActionEntity(
clientActionId = "$type-$createdAt",
routeId = "route-1",
action = type,
weight = 12.5,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
)
}
@@ -0,0 +1,135 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class RouteActionServerReconciliationTest {
@Test
fun matchingFinishedRouteConfirmsDuplicateFinishSafely() {
assertTrue(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uszkodzona plomba."),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Uszkodzona plomba.",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun differingNotesKeepConflictVisibleAndPreserveLocalData() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uwagi z telefonu"),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Inna uwaga na serwerze",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun missingServerPhotoDoesNotDiscardLocalFinish() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = null, notes = null),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(status = "ZAKOŃCZONA", weight = null, notes = null),
),
)
}
@Test
fun laterConfirmedFinishSupersedesAnOlderConflictingFinish() {
assertTrue(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Starsza uwaga"),
hasConfirmedFinishAfter = true,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
@Test
fun completedServerRouteAloneDoesNotDiscardConflictingLocalData() {
assertFalse(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Nadal ważna uwaga"),
hasConfirmedFinishAfter = false,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
private fun finish(weight: Double?, notes: String?): RouteActionEntity =
RouteActionEntity(
clientActionId = "finish-1",
routeId = "route-1",
action = RouteActionType.Finish,
weight = weight,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = """["photo-1"]""",
notes = notes,
)
private fun route(
status: String,
weight: Double?,
notes: String?,
photoClientRequestIds: List<String> = emptyList(),
): DriverRouteDto =
DriverRouteDto(
id = "route-1",
routeDate = "2026-07-23",
startsAt = "",
originName = "Baza",
destinationName = "Cel",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza → Cel",
status = status,
driverStatus = status,
unloadingWeight = weight,
unloadingNotes = notes,
distanceKm = 1.0,
notes = null,
truck = null,
photos = photoClientRequestIds.mapIndexed { index, requestId ->
RoutePhotoDto(
id = "photo-$index",
routeId = "route-1",
clientRequestId = requestId,
source = "camera",
stage = "unloading",
mimeType = "image/jpeg",
size = 1,
url = "https://example.test/photo-$index",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
},
)
}
@@ -125,16 +125,25 @@ class DriverRouteProjectionTest {
}
@Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
fun conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() {
val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)),
actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.FailedConflict,
weight = 11.8,
notes = "Uszkodzona plomba.",
),
),
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(projection.visibleActions)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
@@ -148,8 +157,9 @@ class DriverRouteProjectionTest {
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals("ZAPLANOWANA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
}
@@ -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))