Compare commits

..
15 changed files with 1166 additions and 95 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 119 versionCode = 120
versionName = "1.0.66" versionName = "1.0.67"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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,
@@ -14,6 +14,61 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity? 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'") @Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity> 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.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation 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.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody 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.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -31,6 +33,33 @@ class RouteActionWorker(
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure() val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: 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 photoClientRequestIds = photoClientRequestIds(action)
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) } val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
@@ -72,12 +101,25 @@ class RouteActionWorker(
lastError = null, lastError = null,
attemptIncrement = 0, attemptIncrement = 0,
) )
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate) syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) 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() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation() throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
operation = "route_action_worker", operation = "route_action_worker",
@@ -89,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code, "api_error_code" to error.code,
"api_status_code" to error.statusCode, "api_status_code" to error.statusCode,
"retryable" to error.retryable, "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 { val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable error.retryable -> RouteActionStatus.FailedRetryable
@@ -99,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus( dao.updateStatus(
clientActionId = clientActionId, clientActionId = clientActionId,
status = status, status = status,
lastError = error.message, lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0, attemptIncrement = 0,
) )
@@ -146,3 +210,84 @@ class RouteActionWorker(
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId" 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 { 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,15 @@ 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,
onRetryRouteAction = viewModel::retryConflictedRouteAction,
onCorrectRouteAction = viewModel::correctFailedRouteAction,
) )
DriverScreen.StartRoute -> RouteStageScreen( DriverScreen.StartRoute -> RouteStageScreen(
state = state, state = state,
@@ -436,18 +548,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 +1156,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 +1169,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 +2633,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 +2739,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 +3179,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 +3228,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,11 +3562,13 @@ 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,
onStartRoute: () -> Unit, onStartRoute: () -> Unit,
onFinishRoute: () -> Unit, onFinishRoute: () -> Unit,
onRetryRouteAction: () -> Unit,
onCorrectRouteAction: () -> Unit, onCorrectRouteAction: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -3302,11 +3600,17 @@ 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,
onStart = onStartRoute, onStart = onStartRoute,
onFinish = onFinishRoute, onFinish = onFinishRoute,
onRetry = onRetryRouteAction,
onCorrect = onCorrectRouteAction, onCorrect = onCorrectRouteAction,
) )
} }
@@ -3366,11 +3670,13 @@ 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,
onStart: () -> Unit, onStart: () -> Unit,
onFinish: () -> Unit, onFinish: () -> Unit,
onRetry: () -> Unit,
onCorrect: () -> Unit, onCorrect: () -> Unit,
) { ) {
val steps = routeFlowSteps(route, routeActions, loadingPhotoRequirement, loadingWeightRequirement) 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, container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel,
outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline, outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline,
color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted, color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted,
actionLabel = if (callout.canCorrect) "Popraw dane" else "Odśwież dane", actionLabel = when {
onAction = if (callout.canCorrect) onCorrect else onRefresh, 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( Text(
"Powiadomienie działa w trybie minimalnym.", "Powiadomienie działa w trybie minimalnym.",
color = TppTheme.colors.muted, 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)) 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 +4949,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 +4975,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
@@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent, 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 { fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions 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 projectedRoute = route
var loadingWeightPending = false var loadingWeightPending = false
var unloadingWeightPending = false var unloadingWeightPending = false
@@ -39,25 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
when (action.action) { when (action.action) {
RouteActionType.Start -> { RouteActionType.Start -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
loadingNotes = action.notes, loadingNotes = action.notes,
trackingStatus = "active",
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
) )
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
trackingStatus = "active",
)
}
loadingWeightPending = action.status != RouteActionStatus.Confirmed loadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight, unloadingWeight = action.weight,
unloadingNotes = action.notes, unloadingNotes = action.notes,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(), 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 unloadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
} }
@@ -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
@@ -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() { fun submitStartRoute() {
submitRouteStage(stage = "loading") submitRouteStage(stage = "loading")
} }
@@ -1222,6 +1303,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 +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( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
route = updatedRoute, route = updatedRoute,
@@ -1597,8 +1690,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 +1708,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 +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) { 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 }
}
@@ -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 @Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { fun conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5), 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 steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(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(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false) assertTrue(callout?.isConflict ?: false)
@@ -148,8 +157,9 @@ class DriverRouteProjectionTest {
) )
val steps = routeFlowSteps(projection.route, projection.visibleActions) val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status) assertEquals("ZAPLANOWANA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
} }
@@ -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))