From 431436d0db6d87ae5f57fa26c27aec4b21f868ff Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 17 Jul 2026 13:28:49 +0200 Subject: [PATCH] =?UTF-8?q?Napraw=20synchronizacj=C4=99=20kursu=20w=20apli?= =?UTF-8?q?kacji=20kierowcy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pl/firmatpp/kierowca/DriverApplication.kt | 5 +- .../firmatpp/kierowca/data/ApiErrorMapper.kt | 5 ++ .../kierowca/data/model/DriverModels.kt | 6 ++ .../data/upload/DispatchSheetUploadOutbox.kt | 14 +++ .../data/upload/DispatchSheetUploadWorker.kt | 2 + .../data/upload/OfflineOutboxManager.kt | 46 ++++++++++ .../kierowca/data/upload/PhotoUploadOutbox.kt | 19 +++- .../kierowca/data/upload/PhotoUploadWorker.kt | 2 + .../kierowca/data/upload/RouteActionOutbox.kt | 12 +++ .../kierowca/data/upload/RouteActionWorker.kt | 2 + .../kierowca/data/upload/RoutePointWorker.kt | 2 + .../kierowca/domain/RouteDisplayMapper.kt | 3 +- .../kierowca/sync/DriverSyncWorker.kt | 2 + .../sync/NewRouteNotificationWorker.kt | 2 + .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 52 ++++++----- .../kierowca/ui/DriverRouteProjection.kt | 2 + .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 77 +++++++++++----- .../firmatpp/kierowca/ui/DriverViewModel.kt | 48 +++++++--- .../kierowca/data/ApiErrorMapperTest.kt | 15 ++++ .../data/upload/OfflineOutboxManagerTest.kt | 43 +++++++++ .../upload/PhotoUploadReconciliationTest.kt | 62 +++++++++++++ .../kierowca/domain/RouteDisplayMapperTest.kt | 5 +- .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 87 ++++++++++++++++++- 23 files changed, 448 insertions(+), 65 deletions(-) create mode 100644 app/src/test/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManagerTest.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReconciliationTest.kt diff --git a/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt b/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt index 533f342..7f239fd 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/DriverApplication.kt @@ -16,7 +16,10 @@ class DriverApplication : Application() { AppDiagnostics.installFirebaseCrashlytics() AppDiagnostics.log("app_started") applicationScope.launch { - runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() } + val outboxManager = OfflineOutboxManager(this@DriverApplication) + runCatching { outboxManager.repairOperationalQueues() } + .onFailure { AppDiagnostics.reportNonFatal(it, "repair_operational_queues") } + runCatching { outboxManager.repairGpsQueue() } .onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt index e56da9e..16719cd 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt @@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data import java.io.IOException import java.net.UnknownHostException +import kotlin.coroutines.cancellation.CancellationException import retrofit2.HttpException enum class ApiErrorKind { @@ -106,3 +107,7 @@ object ApiErrorMapper { return mapHttpStatus(exception.code(), body) } } + +fun Throwable.rethrowIfCancellation() { + if (this is CancellationException) throw this +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index a6d5404..906b90d 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -265,6 +265,8 @@ data class DriverRouteDto( val contractCode: String?, val relationLabel: String, val status: String, + val driverStatus: String? = null, + val operationalStatus: String? = null, val loadingWeight: Double? = null, val unloadingWeight: Double? = null, val trackingStatus: String? = null, @@ -273,6 +275,7 @@ data class DriverRouteDto( val notes: String?, val truck: TruckDto?, val completedAt: String? = null, + val operationalCompletedAt: String? = null, val completedByDriver: DriverDto? = null, val originNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null, @@ -280,6 +283,9 @@ data class DriverRouteDto( val photosByStage: RoutePhotosByStageDto? = null, ) +fun DriverRouteDto.driverLifecycleStatus(): String = + driverStatus?.takeIf { it in setOf("ZAPLANOWANA", "W TRAKCIE", "ZAKOŃCZONA") } ?: status + data class RoutePhotosByStageDto( val loading: List = emptyList(), val unloading: List = emptyList(), diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt index d4da374..2159c89 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt @@ -63,12 +63,26 @@ class DispatchSheetUploadOutbox( } suspend fun discard(clientRequestId: String) { + workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(clientRequestId)) dao.find(clientRequestId)?.let { upload -> File(upload.localPath).delete() dao.delete(upload.clientRequestId) } } + suspend fun retry(clientRequestId: String) { + val existing = dao.find(clientRequestId) ?: return + dao.upsert( + existing.copy( + status = PhotoUploadStatus.Pending.storageValue, + progress = 0, + lastError = null, + updatedAtEpochMillis = System.currentTimeMillis(), + ), + ) + enqueueWorker(clientRequestId) + } + private fun enqueueWorker(clientRequestId: String) { val request = OneTimeWorkRequestBuilder() .setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId)) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt index a22201e..1b5c3f4 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt @@ -8,6 +8,7 @@ import java.io.File 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.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.data.sync.NetworkMonitor @@ -68,6 +69,7 @@ class DispatchSheetUploadWorker( dao.delete(clientRequestId) Result.success() }.getOrElse { throwable -> + throwable.rethrowIfCancellation() val error = ApiErrorMapper.map(throwable) AppDiagnostics.reportNonFatal( throwable = throwable, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt index 1d61d84..7190070 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManager.kt @@ -36,6 +36,24 @@ class OfflineOutboxManager(private val context: Context) { } } + suspend fun repairOperationalQueues() { + val photoOutbox = PhotoUploadOutbox(context) + val dispatchSheetOutbox = DispatchSheetUploadOutbox(context) + val routeActionOutbox = RouteActionOutbox(context) + + database.photoUploadDao().allUnsent() + .filter { shouldRepairPhotoUpload(it.status, it.lastError) } + .forEach { photoOutbox.retry(it.clientRequestId) } + + database.dispatchSheetUploadDao().allUnsent() + .filter { shouldRepairPhotoUpload(it.status, it.lastError) } + .forEach { dispatchSheetOutbox.retry(it.clientRequestId) } + + database.routeActionDao().allUnsent() + .filter { shouldRepairRouteAction(it.status, it.lastError) } + .forEach { routeActionOutbox.retry(it.clientActionId) } + } + suspend fun clearAll() { val photos = database.photoUploadDao().allRecords() val dispatchSheets = database.dispatchSheetUploadDao().allRecords() @@ -61,3 +79,31 @@ class OfflineOutboxManager(private val context: Context) { database.routePointDao().deleteAll() } } + +internal fun isWorkerCancellationFailure(lastError: String?): Boolean = + lastError?.let { error -> + error.contains("Job was cancelled", ignoreCase = true) || + error.contains("CancellationException", ignoreCase = true) + } == true + +internal fun isPhotoDependencyFailure(lastError: String?): Boolean = + lastError?.contains("Wymagane zdjęcie nie zostało wysłane", ignoreCase = true) == true + +internal fun shouldRepairPhotoUpload(status: String, lastError: String?): Boolean = + status in setOf( + PhotoUploadStatus.Pending.storageValue, + PhotoUploadStatus.Uploading.storageValue, + PhotoUploadStatus.Verifying.storageValue, + PhotoUploadStatus.FailedRetryable.storageValue, + ) || (status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError)) + +internal fun shouldRepairRouteAction(status: String, lastError: String?): Boolean = + status in setOf( + RouteActionStatus.Pending, + RouteActionStatus.WaitingForPhotos, + RouteActionStatus.Syncing, + RouteActionStatus.FailedRetryable, + ) || ( + status == RouteActionStatus.FailedPermanent && + (isWorkerCancellationFailure(lastError) || isPhotoDependencyFailure(lastError)) + ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt index b6131c1..3488daf 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt @@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.Flow import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.model.RoutePhotoDto class PhotoUploadOutbox( private val context: Context, @@ -74,6 +75,7 @@ class PhotoUploadOutbox( } suspend fun discard(clientRequestId: String) { + workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(clientRequestId)) dao.find(clientRequestId)?.let { upload -> File(upload.localPath).delete() dao.delete(upload.clientRequestId) @@ -81,10 +83,13 @@ class PhotoUploadOutbox( } suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) { - dao.confirmedForRoute(route.id).forEach { upload -> - File(upload.localPath).delete() - dao.delete(upload.clientRequestId) - } + dao.allRecords() + .filter { it.routeId == route.id } + .filter { upload -> + upload.status == PhotoUploadStatus.Confirmed.storageValue || + route.photos.orEmpty().any { serverPhoto -> serverPhotoConfirmsUpload(upload, serverPhoto) } + } + .forEach { upload -> discard(upload.clientRequestId) } } private fun enqueueWorker(clientRequestId: String) { @@ -118,3 +123,9 @@ class PhotoUploadOutbox( return digest.digest().joinToString("") { "%02x".format(it) } } } + +internal fun serverPhotoConfirmsUpload(upload: PhotoUploadEntity, serverPhoto: RoutePhotoDto): Boolean = + serverPhoto.clientRequestId == upload.clientRequestId && ( + serverPhoto.contentSha256.isNullOrBlank() || + serverPhoto.contentSha256.equals(upload.contentSha256, ignoreCase = true) + ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt index cd67801..babe8fc 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt @@ -8,6 +8,7 @@ import java.io.File 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.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.data.sync.NetworkMonitor @@ -70,6 +71,7 @@ class PhotoUploadWorker( enqueueRouteActionsWaitingForPhoto(clientRequestId) Result.success() }.getOrElse { throwable -> + throwable.rethrowIfCancellation() val error = ApiErrorMapper.map(throwable) AppDiagnostics.reportNonFatal( throwable = throwable, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt index a0a6208..0b73014 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt @@ -31,6 +31,18 @@ class RouteActionOutbox( dao.delete(clientActionId) } + suspend fun retry(clientActionId: String) { + val existing = dao.find(clientActionId) ?: return + dao.upsert( + existing.copy( + status = RouteActionStatus.Pending, + lastError = null, + updatedAtEpochMillis = System.currentTimeMillis(), + ), + ) + enqueueWorker(clientActionId) + } + suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List): RouteActionEntity = enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt index 61b7eb4..31aad84 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt @@ -7,6 +7,7 @@ import com.google.gson.Gson 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.FinishRouteBody import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.StartRouteBody @@ -75,6 +76,7 @@ class RouteActionWorker( DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) Result.success() }.getOrElse { throwable -> + throwable.rethrowIfCancellation() val error = ApiErrorMapper.map(throwable) AppDiagnostics.reportNonFatal( throwable = throwable, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt index 6af4026..fec1227 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.delay 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.RoutePointBatchBody import pl.firmatpp.kierowca.data.model.RoutePointDto import pl.firmatpp.kierowca.diagnostics.AppDiagnostics @@ -52,6 +53,7 @@ class RoutePointWorker( } val throwable = outcome.exceptionOrNull() ?: return Result.retry() + throwable.rethrowIfCancellation() val error = ApiErrorMapper.map(throwable) AppDiagnostics.reportNonFatal( throwable = throwable, diff --git a/app/src/main/java/pl/firmatpp/kierowca/domain/RouteDisplayMapper.kt b/app/src/main/java/pl/firmatpp/kierowca/domain/RouteDisplayMapper.kt index 16d02cd..d839cc8 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/domain/RouteDisplayMapper.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/domain/RouteDisplayMapper.kt @@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.domain import kotlin.math.roundToInt import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.model.driverLifecycleStatus data class RouteCardDisplay( val id: String, @@ -32,7 +33,7 @@ object RouteDisplayMapper { destination = route.destinationName.ifBlank { "Cel" }, contractLabel = contract, time = route.startsAt.ifBlank { "--:--" }, - status = route.status, + status = route.driverLifecycleStatus(), distanceLabel = distance, ) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt index a857632..269dbab 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt @@ -13,6 +13,7 @@ import androidx.work.workDataOf import java.util.concurrent.TimeUnit import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorMapper +import pl.firmatpp.kierowca.data.rethrowIfCancellation import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.model.SyncScopeDto @@ -79,6 +80,7 @@ class DriverSyncWorker( Result.success() }.getOrElse { throwable -> + throwable.rethrowIfCancellation() val error = ApiErrorMapper.map(throwable) AppDiagnostics.reportNonFatal( throwable = throwable, diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt index 0de6441..9ce923c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt @@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R 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.sync.NetworkMonitor import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import retrofit2.HttpException @@ -75,6 +76,7 @@ class NewRouteNotificationWorker( NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification) Result.success() }.getOrElse { throwable -> + throwable.rethrowIfCancellation() if (throwable is HttpException && throwable.code() == 404) { Result.success() } else { diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index cf20c86..3cb1e3f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -154,6 +154,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto +import pl.firmatpp.kierowca.data.model.driverLifecycleStatus import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus @@ -319,7 +320,7 @@ fun DriverApp( onWeightChange = viewModel::updateRouteStageWeight, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") }, onPhoto = viewModel::openPhoto, - onDeleteUpload = viewModel::deleteConfirmedUpload, + onDeleteUpload = viewModel::deletePhotoUpload, onRetryUpload = viewModel::retryPhotoUpload, onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, ) @@ -336,7 +337,7 @@ fun DriverApp( onWeightChange = viewModel::updateRouteStageWeight, onUpload = { _, _, _ -> }, onPhoto = viewModel::openPhoto, - onDeleteUpload = viewModel::deleteConfirmedUpload, + onDeleteUpload = viewModel::deletePhotoUpload, onRetryUpload = viewModel::retryPhotoUpload, onSubmit = viewModel::submitStartRoute, ) @@ -353,7 +354,7 @@ fun DriverApp( onWeightChange = viewModel::updateRouteStageWeight, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") }, onPhoto = viewModel::openPhoto, - onDeleteUpload = viewModel::deleteConfirmedUpload, + onDeleteUpload = viewModel::deletePhotoUpload, onRetryUpload = viewModel::retryPhotoUpload, onSubmit = viewModel::submitFinishRoute, ) @@ -1991,7 +1992,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa ) } } - if (hasFailureDetails || canRetryPhotoUpload(upload.status)) { + if (hasFailureDetails || canRetryPhotoUpload(upload.status, upload.lastError)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { if (hasFailureDetails) { IconButton( @@ -2009,7 +2010,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa ) } } - if (canRetryPhotoUpload(upload.status)) { + if (canRetryPhotoUpload(upload.status, upload.lastError)) { Button( onClick = { onRetryUpload(upload) }, colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), @@ -2146,7 +2147,8 @@ private fun routeListTitle(selectedDate: String): String { @Composable private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { val display = RouteDisplayMapper.toCard(route) - val stripColor = routeStatusColor(route.status) + val driverStatus = route.driverLifecycleStatus() + val stripColor = routeStatusColor(driverStatus) Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), @@ -2168,7 +2170,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) { if (compactWidth) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - StatusChip(route.status, stripColor) + StatusChip(driverStatus, stripColor) Text( "Kontrakt: #${display.contractLabel.trimStart('#')}", fontFamily = FontFamily.Monospace, @@ -2182,7 +2184,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - StatusChip(route.status, stripColor) + StatusChip(driverStatus, stripColor) Text( "Kontrakt: #${display.contractLabel.trimStart('#')}", fontFamily = FontFamily.Monospace, @@ -2751,26 +2753,27 @@ private fun RouteStageScreen( val route = state.displaySelectedRoute val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty() val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } - val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size) + val usableStageUploads = routeStageUsableUploads(stageUploads) + val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, usableStageUploads.size) val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement val submitBlocker = when { !showWeightInput -> loadingPhotosSubmitBlocker( serverPhotoCount = stagePhotos.size, - localUploadCount = stageUploads.size, + localUploadCount = usableStageUploads.size, photoRequirement = photoRequirement, ) stage == "loading" -> loadingWeightSubmitBlocker( weightText = state.routeStageWeightText, serverPhotoCount = stagePhotos.size, - localUploadCount = stageUploads.size, + localUploadCount = usableStageUploads.size, weightRequirement = weightRequirement, photoRequirement = photoRequirement, ) else -> routeStageSubmitBlocker( weightText = state.routeStageWeightText, serverPhotoCount = stagePhotos.size, - localUploadCount = stageUploads.size, + localUploadCount = usableStageUploads.size, weightRequirement = weightRequirement, photoRequirement = photoRequirement, ) @@ -3252,7 +3255,7 @@ private fun RouteLifecycleSection( ) } - if (route.status == "W TRAKCIE" && !routeProgressNotificationEnabled) { + if (route.driverLifecycleStatus() == "W TRAKCIE" && !routeProgressNotificationEnabled) { Text( "Powiadomienie działa w trybie minimalnym.", color = TppTheme.colors.muted, @@ -3363,7 +3366,8 @@ private fun RouteFlowStepItem(step: RouteFlowStepUi, modifier: Modifier = Modifi private fun routeFlowStepColor(state: RouteFlowStepState): Color = when (state) { RouteFlowStepState.Todo -> TppTheme.colors.muted - RouteFlowStepState.LocalComplete -> TppTheme.colors.navy + RouteFlowStepState.ServerSaved, + RouteFlowStepState.InProgress -> TppTheme.colors.navy RouteFlowStepState.Syncing -> TppTheme.colors.containerGreen RouteFlowStepState.Confirmed -> TppTheme.colors.forest RouteFlowStepState.NeedsAttention -> TppTheme.colors.error @@ -3373,7 +3377,8 @@ private fun routeFlowStepColor(state: RouteFlowStepState): Color = private fun routeFlowStepBackground(state: RouteFlowStepState): Color = when (state) { RouteFlowStepState.Todo -> TppTheme.colors.panel.copy(alpha = 0.62f) - RouteFlowStepState.LocalComplete -> Color(0xFFE9F2FF) + RouteFlowStepState.ServerSaved, + RouteFlowStepState.InProgress -> Color(0xFFE9F2FF) RouteFlowStepState.Syncing -> TppTheme.colors.successContainer.copy(alpha = 0.75f) RouteFlowStepState.Confirmed -> TppTheme.colors.successContainer RouteFlowStepState.NeedsAttention -> TppTheme.colors.warningContainer @@ -3382,7 +3387,8 @@ private fun routeFlowStepBackground(state: RouteFlowStepState): Color = private fun routeFlowStepIcon(state: RouteFlowStepState): ImageVector = when (state) { RouteFlowStepState.Todo -> Icons.Outlined.Info - RouteFlowStepState.LocalComplete -> Icons.Outlined.LocalShipping + RouteFlowStepState.ServerSaved -> Icons.Outlined.CheckCircle + RouteFlowStepState.InProgress -> Icons.Outlined.LocalShipping RouteFlowStepState.Syncing -> Icons.Outlined.CloudUpload RouteFlowStepState.Confirmed -> Icons.Outlined.CheckCircle RouteFlowStepState.NeedsAttention -> Icons.Outlined.Info @@ -3432,7 +3438,8 @@ private fun leaveConflictCountLabel(count: Int): String = @Composable private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) { - val stripColor = routeStatusColor(route.status) + val driverStatus = route.driverLifecycleStatus() + val stripColor = routeStatusColor(driverStatus) val routeDate = route.routeDate ?: selectedDate Card( colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), @@ -3447,7 +3454,7 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top, ) { - StatusChip(route.status, stripColor) + StatusChip(driverStatus, stripColor) Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { RouteDateStatusChip(routeDate) } @@ -3801,7 +3808,7 @@ private fun PhotoGrid( ) is PhotoGridItem.Upload -> PendingPhotoTile( upload = item.upload, - isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true, + isDeleting = (item.upload.serverPhotoId ?: item.upload.clientRequestId) in deletingPhotoIds, onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) }, onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) }, onRetry = onRetryUpload, @@ -3951,6 +3958,7 @@ private fun PendingPhotoTile( ) { val status = upload.statusType val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null + val canDeleteUpload = canDeleteServerPhoto || canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId) val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError) var showFailureDetails by remember { mutableStateOf(false) } if (showFailureDetails) { @@ -4006,7 +4014,7 @@ private fun PendingPhotoTile( Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) } } - if (!readOnly && canDeleteServerPhoto) { + if (!readOnly && canDeleteUpload) { IconButton( onClick = { onDelete(upload) }, enabled = !isDeleting, @@ -4044,13 +4052,13 @@ private fun PendingPhotoTile( ) } } - if (!readOnly && canRetryPhotoUpload(upload.status)) { + if (!readOnly && canRetryPhotoUpload(upload.status, upload.lastError)) { IconButton( onClick = { onRetry(upload) }, enabled = !isDeleting, modifier = Modifier .align(Alignment.TopEnd) - .padding(8.dp) + .padding(top = if (canDeleteUpload) 56.dp else 8.dp, end = 8.dp) .size(40.dp) .background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) .border(1.dp, TppTheme.colors.forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt index 717271e..7b7fc67 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt @@ -40,6 +40,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List): RouteActionType.Start -> { projectedRoute = projectedRoute.copy( status = "W TRAKCIE", + driverStatus = "W TRAKCIE", loadingWeight = action.weight, trackingStatus = "active", photos = projectedRoute.safePhotos(), @@ -49,6 +50,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List): RouteActionType.Finish -> { projectedRoute = projectedRoute.copy( status = "ZAKOŃCZONA", + driverStatus = "ZAKOŃCZONA", unloadingWeight = action.weight, trackingStatus = "finished", completedAt = action.occurredAt, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index db050c1..dbfc848 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -13,7 +13,10 @@ import kotlin.math.abs import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto +import pl.firmatpp.kierowca.data.model.driverLifecycleStatus import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity +import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus +import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionType @@ -24,7 +27,8 @@ private const val routeWeightMaxTons = 999.999 enum class RouteFlowStepState { Todo, - LocalComplete, + ServerSaved, + InProgress, Syncing, Confirmed, NeedsAttention, @@ -103,10 +107,10 @@ fun routeTodayInstruction( val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate return when { - route.status == "ZAKOŃCZONA" -> "Kurs zakończony." + route.driverLifecycleStatus() == "ZAKOŃCZONA" -> "Kurs zakończony." loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek." today == loadingDate -> "Dziś: załadunek." - today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj." + today == unloadingDate && route.driverLifecycleStatus() == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj." today == unloadingDate -> "Dziś: rozładunek." else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}." } @@ -119,7 +123,7 @@ fun canCompleteRouteFromDriverApp( ): Boolean = runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today - && route.status in setOf("ZAPLANOWANA", "W TRAKCIE") + && route.driverLifecycleStatus() in setOf("ZAPLANOWANA", "W TRAKCIE") fun canStartRouteFromDriverApp( route: DriverRouteDto, @@ -127,14 +131,14 @@ fun canStartRouteFromDriverApp( today: LocalDate = LocalDate.now(), ): Boolean = routeIsActiveToday(route, selectedDate, today) - && route.status == "ZAPLANOWANA" + && route.driverLifecycleStatus() == "ZAPLANOWANA" fun canFinishRouteFromDriverApp( route: DriverRouteDto, selectedDate: String, today: LocalDate = LocalDate.now(), ): Boolean = - route.status == "W TRAKCIE" + route.driverLifecycleStatus() == "W TRAKCIE" && runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today @@ -297,29 +301,30 @@ fun routeFlowSteps( val startOverride = routeActionStepState(actions, RouteActionType.Start) val finishOverride = routeActionStepState(actions, RouteActionType.Finish) val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size + val driverStatus = route.driverLifecycleStatus() val loadingPhotosState = when { - route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed - route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed - route.loadingWeight != null -> RouteFlowStepState.LocalComplete - loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete + startOverride != null -> startOverride + driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed + driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed + loadingPhotoCount > 0 -> RouteFlowStepState.ServerSaved else -> RouteFlowStepState.Todo } val loadingWeightState = startOverride ?: when { - route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed - route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed - route.loadingWeight != null -> RouteFlowStepState.LocalComplete + driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed + driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed + route.loadingWeight != null -> RouteFlowStepState.ServerSaved else -> RouteFlowStepState.Todo } val transitState = when { - route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed - finishOverride == RouteFlowStepState.NeedsAttention -> RouteFlowStepState.LocalComplete - finishOverride == RouteFlowStepState.Syncing -> RouteFlowStepState.LocalComplete - route.status == "W TRAKCIE" -> RouteFlowStepState.LocalComplete + finishOverride != null -> RouteFlowStepState.InProgress + driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed + startOverride != null -> startOverride + driverStatus == "W TRAKCIE" -> RouteFlowStepState.InProgress else -> RouteFlowStepState.Todo } val unloadingState = finishOverride ?: when { - route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed - route.unloadingWeight != null -> RouteFlowStepState.LocalComplete + driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed + route.unloadingWeight != null -> RouteFlowStepState.ServerSaved else -> RouteFlowStepState.Todo } @@ -380,7 +385,8 @@ private fun routeActionStepState(actions: List, actionType: S private fun routeFlowStateLabel(state: RouteFlowStepState): String = when (state) { RouteFlowStepState.Todo -> "do zrobienia" - RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie" + RouteFlowStepState.ServerSaved -> "zapisane na serwerze" + RouteFlowStepState.InProgress -> "w trakcie" RouteFlowStepState.Syncing -> "synchronizacja" RouteFlowStepState.Confirmed -> "potwierdzone" RouteFlowStepState.NeedsAttention -> "wymaga uwagi" @@ -474,6 +480,18 @@ fun routeFactColumns(screenWidthDp: Int): Int = fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int = serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0) +fun photoUploadCanSatisfyStage(status: String): Boolean = + status in setOf( + PhotoUploadStatus.Pending.storageValue, + PhotoUploadStatus.Uploading.storageValue, + PhotoUploadStatus.Verifying.storageValue, + PhotoUploadStatus.Confirmed.storageValue, + PhotoUploadStatus.FailedRetryable.storageValue, + ) + +fun routeStageUsableUploads(uploads: List): List = + uploads.filter { photoUploadCanSatisfyStage(it.status) } + fun queuedPhotoUploadCount(statuses: List): Int = statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" } @@ -485,8 +503,15 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? = serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() } -fun canRetryPhotoUpload(status: String): Boolean = - status == "FAILED_RETRYABLE" +fun canRetryPhotoUpload(status: String, lastError: String? = null): Boolean = + status == PhotoUploadStatus.FailedRetryable.storageValue || + (status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError)) + +fun canDiscardLocalPhotoUpload(status: String, serverPhotoId: String?): Boolean = + serverPhotoId.isNullOrBlank() && status in setOf( + PhotoUploadStatus.FailedRetryable.storageValue, + PhotoUploadStatus.FailedPermanent.storageValue, + ) fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean = status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank() @@ -514,6 +539,14 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? { ) } +fun routeScreenAfterServerRefresh(currentScreen: DriverScreen, route: DriverRouteDto): DriverScreen = + when { + currentScreen in setOf(DriverScreen.StartRoute, DriverScreen.LoadingWeight) && + route.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA") -> DriverScreen.Detail + currentScreen == DriverScreen.FinishRoute && route.driverLifecycleStatus() == "ZAKOŃCZONA" -> DriverScreen.Detail + else -> currentScreen + } + fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean = !requirePreciseLocation || hasFineLocation diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index a63414f..22f8548 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -29,6 +29,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto +import pl.firmatpp.kierowca.data.model.driverLifecycleStatus import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity @@ -40,6 +41,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionOutbox import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionType +import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry import pl.firmatpp.kierowca.diagnostics.DiagnosticSection @@ -131,7 +133,7 @@ data class DriverUiState( val displayRoutes: List get() = projectDriverRoutes(routes, projectionActions) .sortedWith( - compareBy { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 } + compareBy { route -> if (route.driverLifecycleStatus() == "ZAKOŃCZONA") 1 else 0 } .thenBy { route -> routeDayOrdering(route, selectedDate) }, ) @@ -529,6 +531,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { it.copy( + screen = routeScreenAfterServerRefresh(it.screen, response.route), selectedRoute = response.route, routes = it.routes.map { route -> if (route.id == routeId) response.route else route }, isStale = cached.stale || !it.isOnline, @@ -1040,7 +1043,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val snapshot = _state.value val route = snapshot.selectedRoute ?: return val stagePhotos = routePhotosForStage(route.photos, "loading") - val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" } + val stageUploads = routeStageUsableUploads( + snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" }, + ) val blocker = loadingPhotosSubmitBlocker( serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, @@ -1092,16 +1097,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ?: return viewModelScope.launch { - runCatching { routeActionOutbox.discard(action.clientActionId) } + val interrupted = isWorkerCancellationFailure(action.lastError) + runCatching { + if (interrupted) routeActionOutbox.retry(action.clientActionId) + else routeActionOutbox.discard(action.clientActionId) + } .onSuccess { _state.update { it.copy( - routeActions = it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId }, - visibleRouteActions = it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId }, + routeActions = if (interrupted) it.routeActions else it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId }, + visibleRouteActions = if (interrupted) it.visibleRouteActions else it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId }, + feedback = if (interrupted) "Ponawiam potwierdzenie z tym samym identyfikatorem operacji." else it.feedback, error = null, ) } - if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute() + if (!interrupted) { + if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute() + } } .onFailure { throwable -> _state.update { it.withApiError(throwable) } } } @@ -1142,7 +1154,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) null } val stagePhotos = routePhotosForStage(route.photos, stage) - val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } + val stageUploads = routeStageUsableUploads( + snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }, + ) val submitBlocker = if (stage == "loading") { loadingWeightSubmitBlocker( weightText = snapshot.routeStageWeightText, @@ -1188,6 +1202,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds) route.copy( status = "W TRAKCIE", + driverStatus = "W TRAKCIE", loadingWeight = weight, trackingStatus = "active", photos = route.safePhotos(), @@ -1197,6 +1212,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds) route.copy( status = "ZAKOŃCZONA", + driverStatus = "ZAKOŃCZONA", unloadingWeight = weight, trackingStatus = "finished", completedAt = Instant.now().toString(), @@ -1296,13 +1312,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } - fun deleteConfirmedUpload(upload: PhotoUploadEntity) { + fun deletePhotoUpload(upload: PhotoUploadEntity) { val route = _state.value.selectedRoute ?: return + val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) + if (serverPhotoId == null) { + if (!canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)) return + viewModelScope.launch { + _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + upload.clientRequestId, error = null) } + runCatching { photoUploadOutbox.discard(upload.clientRequestId) } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - upload.clientRequestId) } + } + return + } if (!_state.value.isOnline) { _state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") } return } - val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return viewModelScope.launch { _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) } @@ -1464,7 +1490,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } private fun reconcileTrackingService(routes: List, driver: DriverDto) { - val activeRoute = routes.firstOrNull { it.status == "W TRAKCIE" || it.trackingStatus == "active" } + val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" } if (activeRoute == null) { ActiveRouteTrackingService.stop(getApplication()) } else { @@ -1478,7 +1504,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { - if (route.status == "W TRAKCIE" || route.trackingStatus == "active") { + if (route.driverLifecycleStatus() == "W TRAKCIE") { if (driver != null) { ActiveRouteTrackingService.start( context = getApplication(), diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt index 08b3283..a3adb0e 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt @@ -2,12 +2,27 @@ package pl.firmatpp.kierowca.data import java.io.IOException import java.net.UnknownHostException +import kotlin.coroutines.cancellation.CancellationException import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test class ApiErrorMapperTest { + @Test + fun rethrowsWorkerCancellationInsteadOfMappingItAsApplicationFailure() { + val cancellation = CancellationException("Job was cancelled") + + try { + cancellation.rethrowIfCancellation() + fail("CancellationException should be rethrown") + } catch (thrown: CancellationException) { + assertSame(cancellation, thrown) + } + } + @Test fun mapsNetworkFailuresToRetryableOfflineMessage() { val error = ApiErrorMapper.map(IOException("timeout")) diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManagerTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManagerTest.kt new file mode 100644 index 0000000..4fe55bf --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/OfflineOutboxManagerTest.kt @@ -0,0 +1,43 @@ +package pl.firmatpp.kierowca.data.upload + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OfflineOutboxManagerTest { + @Test + fun recognizesLegacyCancellationFailureDetails() { + assertTrue(isWorkerCancellationFailure("Typ: błąd aplikacji\nKomunikat: Job was cancelled")) + assertTrue(isWorkerCancellationFailure("kotlinx.coroutines.JobCancellationException")) + assertFalse(isWorkerCancellationFailure("HTTP 422: Niepoprawne zdjęcie")) + assertFalse(isWorkerCancellationFailure(null)) + } + + @Test + fun repairsInterruptedAndRetryablePhotoUploadsButNotRealPermanentFailures() { + assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Pending.storageValue, null)) + assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Uploading.storageValue, null)) + assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Verifying.storageValue, null)) + assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedRetryable.storageValue, "timeout")) + assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "Job was cancelled")) + assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "HTTP 422")) + assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.Cancelled.storageValue, null)) + } + + @Test + fun repairsInterruptedRouteActionWithItsOriginalIdempotencyKey() { + assertTrue(shouldRepairRouteAction(RouteActionStatus.Pending, null)) + assertTrue(shouldRepairRouteAction(RouteActionStatus.WaitingForPhotos, null)) + assertTrue(shouldRepairRouteAction(RouteActionStatus.Syncing, null)) + assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedRetryable, "timeout")) + assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Job was cancelled")) + assertTrue( + shouldRepairRouteAction( + RouteActionStatus.FailedPermanent, + "Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.", + ), + ) + assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Brakuje zdjęcia")) + assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedConflict, "Kurs już rozpoczęty")) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReconciliationTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReconciliationTest.kt new file mode 100644 index 0000000..bf87943 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadReconciliationTest.kt @@ -0,0 +1,62 @@ +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.RoutePhotoDto + +class PhotoUploadReconciliationTest { + @Test + fun serverReceiptIdentityClearsFalseLocalFailure() { + val upload = upload( + clientRequestId = "request-1", + contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + + assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", upload.contentSha256.uppercase()))) + assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-2", upload.contentSha256))) + assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))) + } + + @Test + fun legacyServerPhotoWithoutHashStillConfirmsMatchingRequestId() { + val upload = upload("request-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + + assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", null))) + } + + private fun upload(clientRequestId: String, contentSha256: String): PhotoUploadEntity = + PhotoUploadEntity( + clientRequestId = clientRequestId, + routeId = "10", + localPath = "/tmp/$clientRequestId.jpg", + source = "camera", + takenAt = null, + latitude = null, + longitude = null, + locationAccuracyMeters = null, + mimeType = "image/jpeg", + size = 100, + contentSha256 = contentSha256, + status = PhotoUploadStatus.FailedPermanent.storageValue, + lastError = "Typ: błąd aplikacji\nKomunikat: Job was cancelled", + ) + + private fun serverPhoto(clientRequestId: String, contentSha256: String?): RoutePhotoDto = + RoutePhotoDto( + id = "42", + routeId = "10", + clientRequestId = clientRequestId, + contentSha256 = contentSha256, + source = "camera", + stage = "loading", + mimeType = "image/jpeg", + size = 100, + url = "https://example.test/photo/42", + takenAt = null, + latitude = null, + longitude = null, + locationAccuracyMeters = null, + createdAt = null, + ) +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/domain/RouteDisplayMapperTest.kt b/app/src/test/java/pl/firmatpp/kierowca/domain/RouteDisplayMapperTest.kt index 3d8ea4e..2dff5f7 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/domain/RouteDisplayMapperTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/domain/RouteDisplayMapperTest.kt @@ -16,7 +16,9 @@ class RouteDisplayMapperTest { contractName = "Odpady przemyslowe", contractCode = "TPP-ODP-24", relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask", - status = "ZAPLANOWANA", + status = "ZAKOŃCZONA", + driverStatus = "ZAPLANOWANA", + operationalStatus = "ZAKOŃCZONA", distanceKm = 91.4, notes = null, truck = null, @@ -29,6 +31,7 @@ class RouteDisplayMapperTest { assertEquals("Instalacja Odpadowa Slask", display.destination) assertEquals("TPP-ODP-24", display.contractLabel) assertEquals("09:15", display.time) + assertEquals("ZAPLANOWANA", display.status) assertEquals("91 km", display.distanceLabel) } } diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index 3baeed8..b8c55ec 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -205,8 +205,9 @@ class DriverUiRulesTest { } @Test - fun allowsRetryActionOnlyForRetryableFailedUploads() { + fun allowsRetryForRetryableAndLegacyCancelledUploads() { assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE")) + assertTrue(canRetryPhotoUpload("FAILED_PERMANENT", "Typ: błąd aplikacji\nKomunikat: Job was cancelled")) assertFalse(canRetryPhotoUpload("PENDING")) assertFalse(canRetryPhotoUpload("UPLOADING")) assertFalse(canRetryPhotoUpload("VERIFYING")) @@ -215,6 +216,37 @@ class DriverUiRulesTest { assertFalse(canRetryPhotoUpload("CANCELLED")) } + @Test + fun permanentUploadDoesNotSatisfyRouteStageAndCanBeDiscarded() { + val failed = upload(status = "FAILED_PERMANENT") + val pending = upload(status = "PENDING").copy(clientRequestId = "pending-upload") + + assertEquals(listOf("pending-upload"), routeStageUsableUploads(listOf(failed, pending)).map { it.clientRequestId }) + assertFalse(photoUploadCanSatisfyStage("FAILED_PERMANENT")) + assertTrue(canDiscardLocalPhotoUpload("FAILED_PERMANENT", null)) + assertFalse(canDiscardLocalPhotoUpload("CONFIRMED", "42")) + } + + @Test + fun serverRefreshLeavesObsoleteRouteStageScreen() { + assertEquals( + DriverScreen.Detail, + routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "W TRAKCIE")), + ) + assertEquals( + DriverScreen.Detail, + routeScreenAfterServerRefresh(DriverScreen.LoadingWeight, route(status = "ZAKOŃCZONA")), + ) + assertEquals( + DriverScreen.Detail, + routeScreenAfterServerRefresh(DriverScreen.FinishRoute, route(status = "ZAKOŃCZONA")), + ) + assertEquals( + DriverScreen.StartRoute, + routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "ZAPLANOWANA")), + ) + } + @Test fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() { assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera")) @@ -365,18 +397,62 @@ class DriverUiRulesTest { assertEquals(RouteLifecycleAction.Start, action?.action) } + @Test + fun driverFlowIgnoresOperationalStatusFromGeofence() { + val route = route( + status = "ZAKOŃCZONA", + driverStatus = "ZAPLANOWANA", + operationalStatus = "ZAKOŃCZONA", + ) + + val steps = routeFlowSteps(route, emptyList()) + + assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today)) + assertEquals(RouteFlowStepState.Todo, steps[0].state) + assertEquals(RouteFlowStepState.Todo, steps[1].state) + assertEquals(RouteFlowStepState.Todo, steps[2].state) + assertEquals(DriverScreen.StartRoute, routeScreenAfterServerRefresh(DriverScreen.StartRoute, route)) + } + @Test fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() { val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading"))) val steps = routeFlowSteps(route, emptyList()) - assertEquals(RouteFlowStepState.LocalComplete, steps[0].state) + assertEquals(RouteFlowStepState.ServerSaved, steps[0].state) + assertEquals("zapisane na serwerze", steps[0].stateLabel) assertEquals(RouteFlowStepState.Todo, steps[1].state) assertEquals(RouteFlowStepState.Todo, steps[2].state) assertEquals(RouteFlowStepState.Todo, steps[3].state) } + @Test + fun freshInstallLabelsBackendLoadingDocumentationAsServerData() { + val route = route( + status = "ZAPLANOWANA", + loadingWeight = 23.76, + photos = listOf(photo("loading-1", stage = "loading")), + ) + + val steps = routeFlowSteps(route, emptyList()) + + assertEquals(RouteFlowStepState.ServerSaved, steps[0].state) + assertEquals("zapisane na serwerze", steps[0].stateLabel) + assertEquals(RouteFlowStepState.ServerSaved, steps[1].state) + assertEquals("zapisane na serwerze", steps[1].stateLabel) + assertEquals(RouteFlowStepState.Todo, steps[2].state) + assertEquals(RouteFlowStepState.Todo, steps[3].state) + } + + @Test + fun serverWeightDoesNotMarkMissingLoadingPhotoAsComplete() { + val steps = routeFlowSteps(route(status = "ZAPLANOWANA", loadingWeight = 23.76), emptyList()) + + assertEquals(RouteFlowStepState.Todo, steps[0].state) + assertEquals(RouteFlowStepState.ServerSaved, steps[1].state) + } + @Test fun activeRouteShowsTransitStateAndFinishAction() { val route = route(status = "W TRAKCIE", loadingWeight = 12.5) @@ -386,7 +462,8 @@ class DriverUiRulesTest { assertEquals(RouteFlowStepState.Confirmed, steps[0].state) assertEquals(RouteFlowStepState.Confirmed, steps[1].state) - assertEquals(RouteFlowStepState.LocalComplete, steps[2].state) + assertEquals(RouteFlowStepState.InProgress, steps[2].state) + assertEquals("w trakcie", steps[2].stateLabel) assertEquals(RouteFlowStepState.Todo, steps[3].state) assertEquals("Rozładuj", action?.label) assertEquals(RouteLifecycleAction.Finish, action?.action) @@ -527,6 +604,8 @@ class DriverUiRulesTest { private fun route( status: String = "ZAPLANOWANA", + driverStatus: String? = null, + operationalStatus: String? = null, loadingWeight: Double? = null, unloadingWeight: Double? = null, photos: List = emptyList(), @@ -545,6 +624,8 @@ class DriverUiRulesTest { contractCode = "TPP-1", relationLabel = "Baza -> Instalacja", status = status, + driverStatus = driverStatus, + operationalStatus = operationalStatus, loadingWeight = loadingWeight, unloadingWeight = unloadingWeight, distanceKm = 1.0,