From 956be6a6dee1c72bdbfc20ed5b94a33c2a68f821 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 31 Jul 2026 23:11:24 +0200 Subject: [PATCH] =?UTF-8?q?Obs=C5=82u=C5=BC=20konfigurowalne=20kroki=20i?= =?UTF-8?q?=20dokumenty=20trasy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kierowca/data/DriverRepository.kt | 9 + .../kierowca/data/api/MobileDriverApi.kt | 3 + .../kierowca/data/model/DriverModels.kt | 36 +++ .../kierowca/data/upload/DriverDatabase.kt | 13 +- .../kierowca/data/upload/PhotoUploadEntity.kt | 3 + .../kierowca/data/upload/PhotoUploadOutbox.kt | 7 + .../kierowca/data/upload/RouteActionEntity.kt | 2 + .../kierowca/data/upload/RouteActionOutbox.kt | 12 +- .../kierowca/data/upload/RouteActionWorker.kt | 12 + .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 276 +++++++++++++++++- .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 71 +++++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 69 ++++- .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 50 ++++ 13 files changed, 538 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index 414cbd1..c459a64 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -202,6 +202,9 @@ class DriverRepository( source: String, stage: String = "other", metadata: PhotoUploadMetadata = PhotoUploadMetadata(), + workflowVersion: String? = null, + workflowStepId: String? = null, + workflowDocumentId: String? = null, ): PhotoUploadResponse { val resolver = context.contentResolver val mimeType = resolver.getType(uri) ?: "image/jpeg" @@ -222,6 +225,9 @@ class DriverRepository( contentSha256.toPlainTextBody(), sourceBody, stage.toPlainTextBody(), + workflowVersion?.toPlainTextBody(), + workflowStepId?.toPlainTextBody(), + workflowDocumentId?.toPlainTextBody(), metadataParts["takenAt"], metadataParts["latitude"], metadataParts["longitude"], @@ -249,6 +255,9 @@ class DriverRepository( upload.contentSha256.toPlainTextBody(), upload.source.toPlainTextBody(), upload.stage.toPlainTextBody(), + upload.workflowVersion?.toPlainTextBody(), + upload.workflowStepId?.toPlainTextBody(), + upload.workflowDocumentId?.toPlainTextBody(), metadataParts["takenAt"], metadataParts["latitude"], metadataParts["longitude"], diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index 494d73d..7b1374c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -216,6 +216,9 @@ interface MobileDriverApi { @Part("contentSha256") contentSha256: RequestBody, @Part("source") source: RequestBody, @Part("stage") stage: RequestBody?, + @Part("workflowVersion") workflowVersion: RequestBody?, + @Part("workflowStepId") workflowStepId: RequestBody?, + @Part("workflowDocumentId") workflowDocumentId: RequestBody?, @Part("takenAt") takenAt: RequestBody?, @Part("latitude") latitude: RequestBody?, @Part("longitude") longitude: RequestBody?, 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 33e0f32..dc6e230 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 @@ -78,6 +78,7 @@ data class DriverAppSettingsDto( val loadingWeightRequirement: String? = null, val unloadingPhotoRequirement: String? = null, val unloadingWeightRequirement: String? = null, + val routeWorkflow: DriverRouteWorkflowDto? = null, val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null, @@ -85,6 +86,33 @@ data class DriverAppSettingsDto( val leaveRequests: LeaveRequestsConfigDto? = null, ) +data class DriverRouteWorkflowDto( + val version: String, + val customized: Boolean = false, + val loading: DriverRouteStageWorkflowDto, + val unloading: DriverRouteStageWorkflowDto, +) + +data class DriverRouteStageWorkflowDto( + val steps: List = emptyList(), +) + +data class DriverRouteWorkflowStepDto( + val id: String, + val type: String, + val title: String, + val description: String = "", + val requirement: String = "required", + val documents: List = emptyList(), +) + +data class DriverRouteWorkflowDocumentDto( + val id: String, + val label: String, + val description: String = "", + val requirement: String = "required", +) + data class LeaveRequestsConfigDto( val enabled: Boolean = false, val types: List = listOf("URLOP"), @@ -522,6 +550,10 @@ data class RoutePhotoDto( val contentSha256: String? = null, val source: String, val stage: String = "other", + val workflowVersion: String? = null, + val workflowStepId: String? = null, + val workflowDocumentId: String? = null, + val originalName: String? = null, val mimeType: String?, val size: Long, val url: String, @@ -552,6 +584,8 @@ data class StartRouteBody( val occurredAt: String, val photoClientRequestIds: List, val loadingNotes: String? = null, + val workflowVersion: String? = null, + val completedStepIds: List = emptyList(), ) data class FinishRouteBody( @@ -560,6 +594,8 @@ data class FinishRouteBody( val occurredAt: String, val photoClientRequestIds: List, val unloadingNotes: String? = null, + val workflowVersion: String? = null, + val completedStepIds: List = emptyList(), ) data class RouteActionResponse( diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt index 6be680d..e00e25c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt @@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity DriverRouteCacheEntity::class, DriverSyncStateEntity::class, ], - version = 7, + version = 8, exportSchema = false, ) abstract class DriverDatabase : RoomDatabase() { @@ -208,6 +208,16 @@ abstract class DriverDatabase : RoomDatabase() { } } + private val migration7To8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowVersion TEXT") + db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowStepId TEXT") + db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowDocumentId TEXT") + db.execSQL("ALTER TABLE route_actions ADD COLUMN workflowVersion TEXT") + db.execSQL("ALTER TABLE route_actions ADD COLUMN completedStepIdsJson TEXT NOT NULL DEFAULT '[]'") + } + } + fun get(context: Context): DriverDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -221,6 +231,7 @@ abstract class DriverDatabase : RoomDatabase() { migration4To5, migration5To6, migration6To7, + migration7To8, ).build().also { instance = it } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt index 8f002c6..5966a7c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt @@ -17,6 +17,9 @@ data class PhotoUploadEntity( val localPath: String, val source: String, val stage: String = "other", + val workflowVersion: String? = null, + val workflowStepId: String? = null, + val workflowDocumentId: String? = null, val takenAt: String?, val latitude: Double?, val longitude: Double?, 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 90f8e85..266fc9a 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 @@ -37,12 +37,16 @@ class PhotoUploadOutbox( source: String, metadata: PhotoUploadMetadata, stage: String = "other", + workflowVersion: String? = null, + workflowStepId: String? = null, + workflowDocumentId: String? = null, ): PhotoUploadEntity = withContext(Dispatchers.IO) { val clientRequestId = UUID.randomUUID().toString() val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val extension = when (mimeType) { "image/png" -> "png" "image/webp" -> "webp" + "application/pdf" -> "pdf" else -> "jpg" } val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() } @@ -54,6 +58,9 @@ class PhotoUploadOutbox( localPath = localFile.absolutePath, source = source, stage = stage, + workflowVersion = workflowVersion, + workflowStepId = workflowStepId, + workflowDocumentId = workflowDocumentId, takenAt = metadata.takenAt, latitude = metadata.latitude, longitude = metadata.longitude, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt index f280705..1124f95 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt @@ -33,6 +33,8 @@ data class RouteActionEntity( val weight: Double?, val occurredAt: String, val photoClientRequestIdsJson: String, + val workflowVersion: String? = null, + val completedStepIdsJson: String = "[]", val status: String = RouteActionStatus.Pending, val attemptCount: Int = 0, val lastError: String? = null, 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 cb28c62..54708af 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 @@ -48,16 +48,20 @@ class RouteActionOutbox( loadingWeight: Double?, photoClientRequestIds: List, notes: String? = null, + workflowVersion: String? = null, + completedStepIds: List = emptyList(), ): RouteActionEntity = - enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes) + enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds) suspend fun enqueueFinish( routeId: String, unloadingWeight: Double?, photoClientRequestIds: List, notes: String? = null, + workflowVersion: String? = null, + completedStepIds: List = emptyList(), ): RouteActionEntity = - enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes) + enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds) private suspend fun enqueue( action: String, @@ -65,6 +69,8 @@ class RouteActionOutbox( weight: Double?, photoClientRequestIds: List, notes: String?, + workflowVersion: String?, + completedStepIds: List, ): RouteActionEntity { dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) val entity = RouteActionEntity( @@ -74,6 +80,8 @@ class RouteActionOutbox( weight = weight, occurredAt = Instant.now().toString(), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), + workflowVersion = workflowVersion, + completedStepIdsJson = gson.toJson(completedStepIds.distinct()), notes = notes?.trim()?.takeIf { it.isNotEmpty() }, ) 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 3ee3dcb..de1373d 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 @@ -181,6 +181,8 @@ class RouteActionWorker( occurredAt = action.occurredAt, photoClientRequestIds = photoClientRequestIds, loadingNotes = action.notes, + workflowVersion = action.workflowVersion, + completedStepIds = completedStepIds(action), ), ) RouteActionType.Finish -> repository.finishRoute( @@ -191,6 +193,8 @@ class RouteActionWorker( occurredAt = action.occurredAt, photoClientRequestIds = photoClientRequestIds, unloadingNotes = action.notes, + workflowVersion = action.workflowVersion, + completedStepIds = completedStepIds(action), ), ) else -> error("Nieznana akcja kursu: ${action.action}") @@ -204,6 +208,14 @@ class RouteActionWorker( .filter { it.isNotBlank() } .distinct() + private fun completedStepIds(action: RouteActionEntity): List = + runCatching { + gson.fromJson(action.completedStepIdsJson, Array::class.java)?.toList().orEmpty() + }.getOrDefault(emptyList()) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + companion object { const val KEY_CLIENT_ACTION_ID = "clientActionId" 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 2aeb3cd..72a5388 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -166,6 +166,7 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto 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.DriverRouteWorkflowStepDto import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.TachographReminderDto @@ -480,23 +481,24 @@ fun DriverApp( ) DriverScreen.StartRoute -> RouteStageScreen( state = state, - title = if (routeStageRequirementIsVisible(state.loadingPhotoRequirement)) "Zdjęcia załadunku" else "Potwierdź załadunek", + title = if (state.routeWorkflow != null) "Załadunek" else if (routeStageRequirementIsVisible(state.loadingPhotoRequirement)) "Zdjęcia załadunku" else "Potwierdź załadunek", stage = "loading", weightLabel = "Tonaż załadunku", - submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek", - showWeightInput = false, - showNotesInput = !routeStageRequirementIsVisible(state.loadingWeightRequirement), + submitLabel = if (state.routeWorkflow != null) "Potwierdź załadunek" else if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek", + showWeightInput = state.routeWorkflow?.let { routeWorkflowSteps(it, "loading").any { step -> step.type == RouteWorkflowStepType.Weight } } ?: false, + showNotesInput = state.routeWorkflow != null || !routeStageRequirementIsVisible(state.loadingWeightRequirement), showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement), showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement), onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, onNotesChange = viewModel::updateRouteStageNotes, - onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading")?.join() }, + onUpload = { uri, source, metadata, stepId, documentId -> viewModel.uploadPhoto(uri, source, metadata, "loading", stepId, documentId)?.join() }, + onStepToggle = viewModel::toggleRouteStageStep, onPhoto = viewModel::openPhoto, onDeletePhoto = viewModel::deletePhoto, onDeleteUpload = viewModel::deletePhotoUpload, onRetryUpload = viewModel::retryPhotoUpload, - onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, + onSubmit = if (state.routeWorkflow == null && routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, ) DriverScreen.LoadingWeight -> RouteStageScreen( state = state, @@ -511,7 +513,8 @@ fun DriverApp( onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, onNotesChange = viewModel::updateRouteStageNotes, - onUpload = { _, _, _ -> }, + onUpload = { _, _, _, _, _ -> }, + onStepToggle = viewModel::toggleRouteStageStep, onPhoto = viewModel::openPhoto, onDeletePhoto = viewModel::deletePhoto, onDeleteUpload = viewModel::deletePhotoUpload, @@ -531,7 +534,8 @@ fun DriverApp( onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, onNotesChange = viewModel::updateRouteStageNotes, - onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading")?.join() }, + onUpload = { uri, source, metadata, stepId, documentId -> viewModel.uploadPhoto(uri, source, metadata, "unloading", stepId, documentId)?.join() }, + onStepToggle = viewModel::toggleRouteStageStep, onPhoto = viewModel::openPhoto, onDeletePhoto = viewModel::deletePhoto, onDeleteUpload = viewModel::deletePhotoUpload, @@ -4427,7 +4431,8 @@ private fun RouteStageScreen( onBack: () -> Unit, onWeightChange: (String) -> Unit, onNotesChange: (String) -> Unit, - onUpload: suspend (Uri, String, PhotoUploadMetadata) -> Unit, + onUpload: suspend (Uri, String, PhotoUploadMetadata, String?, String?) -> Unit, + onStepToggle: (String, Boolean) -> Unit, onPhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit, @@ -4442,14 +4447,27 @@ private fun RouteStageScreen( var multiplePhotoLocationMetadata by remember(stage) { mutableStateOf(PhotoUploadMetadata()) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) } + var cameraAttachmentTarget by remember(stage) { mutableStateOf(null) } + var pickerAttachmentTarget by remember(stage) { mutableStateOf(null) } val route = state.displaySelectedRoute val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty() val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val usableStageUploads = routeStageUsableUploads(stageUploads) - val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, usableStageUploads.size) + val cargoStagePhotos = stagePhotos.filter { it.workflowDocumentId.isNullOrBlank() } + val cargoStageUploads = usableStageUploads.filter { it.workflowDocumentId.isNullOrBlank() } + val attachmentCount = visiblePhotoAttachmentCount(cargoStagePhotos.size, cargoStageUploads.size) val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement - val submitBlocker = when { + val submitBlocker = if (state.routeWorkflow != null) { + routeWorkflowSubmitBlocker( + workflow = state.routeWorkflow, + stage = stage, + completedStepIds = state.routeStageCompletedStepIds, + weightText = state.routeStageWeightText, + serverPhotos = stagePhotos, + localUploads = usableStageUploads, + ) + } else when { !showWeightInput -> loadingPhotosSubmitBlocker( serverPhotoCount = stagePhotos.size, localUploadCount = usableStageUploads.size, @@ -4488,10 +4506,17 @@ private fun RouteStageScreen( ) ) { coroutineScope.launch { - onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) + onUpload( + capturedUri, + "camera", + cameraPhotoMetadata(context), + cameraAttachmentTarget?.stepId, + cameraAttachmentTarget?.documentId, + ) } } cameraUri = null + cameraAttachmentTarget = null } val openMultiplePhotoCamera = { multiplePhotoLocationMetadata = PhotoUploadMetadata() @@ -4530,9 +4555,30 @@ private fun RouteStageScreen( val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) { coroutineScope.launch { - onUpload(uri, "gallery", PhotoUploadMetadata()) + onUpload( + uri, + "gallery", + PhotoUploadMetadata(), + pickerAttachmentTarget?.stepId, + pickerAttachmentTarget?.documentId, + ) } } + pickerAttachmentTarget = null + } + val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) { + coroutineScope.launch { + onUpload( + uri, + "file", + PhotoUploadMetadata(), + pickerAttachmentTarget?.stepId, + pickerAttachmentTarget?.documentId, + ) + } + } + pickerAttachmentTarget = null } if (showMultiplePhotoCamera) { @@ -4543,6 +4589,8 @@ private fun RouteStageScreen( uri, "camera", multiplePhotoLocationMetadata.copy(takenAt = Instant.now().toString()), + null, + null, ) }, onClose = { @@ -4573,7 +4621,42 @@ private fun RouteStageScreen( if (route != null) { item { RouteStageSummaryCard(route) } } - item { + if (state.routeWorkflow != null) { + item { + RouteWorkflowStepsCard( + steps = routeWorkflowSteps(state.routeWorkflow, stage), + completedStepIds = state.routeStageCompletedStepIds, + weightText = state.routeStageWeightText, + serverPhotos = stagePhotos, + localUploads = usableStageUploads, + onStepToggle = onStepToggle, + onTakeDocumentPhoto = { stepId, documentId -> + cameraAttachmentTarget = RouteWorkflowAttachmentTarget(stepId, documentId) + val newUri = createCameraUri(context) + cameraUri = newUri + pendingCameraMode = RouteStageCameraMode.Single + val missingPermissions = cameraCapturePermissions( + context = context, + requirePreciseLocation = state.requirePreciseLocationForPhotos, + ) + if (missingPermissions.isEmpty()) { + pendingCameraMode = null + cameraLauncher.launch(newUri) + } else { + cameraPermissionLauncher.launch(missingPermissions) + } + }, + onPickDocumentPhoto = { stepId, documentId -> + pickerAttachmentTarget = RouteWorkflowAttachmentTarget(stepId, documentId) + pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + onPickDocumentFile = { stepId, documentId -> + pickerAttachmentTarget = RouteWorkflowAttachmentTarget(stepId, documentId) + filePickerLauncher.launch(arrayOf("application/pdf", "image/jpeg", "image/png")) + }, + ) + } + } else item { RouteStageChecklist( hasValidWeight = if (showWeightInput) { state.routeStageWeightText.trim().isNotBlank() && routeStageSubmitBlocker( @@ -4633,6 +4716,7 @@ private fun RouteStageScreen( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { RouteStageCameraActions( onSinglePhoto = { + cameraAttachmentTarget = null val newUri = createCameraUri(context) cameraUri = newUri pendingCameraMode = RouteStageCameraMode.Single @@ -4648,6 +4732,7 @@ private fun RouteStageScreen( } }, onMultiplePhotos = { + cameraAttachmentTarget = null cameraUri = null pendingCameraMode = RouteStageCameraMode.Multiple val missingPermissions = cameraCapturePermissions( @@ -4664,6 +4749,7 @@ private fun RouteStageScreen( ) if (state.allowGalleryUploads) { CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { + pickerAttachmentTarget = null pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } } @@ -4673,8 +4759,8 @@ private fun RouteStageScreen( if (showPhotoGrid) { item { PhotoGrid( - photos = stagePhotos, - uploads = stageUploads, + photos = cargoStagePhotos, + uploads = stageUploads.filter { it.workflowDocumentId.isNullOrBlank() }, deletingPhotoIds = state.deletingPhotoIds, imageAuthHeader = state.imageAuthHeader, onPhoto = onPhoto, @@ -4718,6 +4804,164 @@ private fun RouteStageScreen( } } +private data class RouteWorkflowAttachmentTarget( + val stepId: String, + val documentId: String, +) + +@Composable +private fun RouteWorkflowStepsCard( + steps: List, + completedStepIds: Set, + weightText: String, + serverPhotos: List, + localUploads: List, + onStepToggle: (String, Boolean) -> Unit, + onTakeDocumentPhoto: (String, String) -> Unit, + onPickDocumentPhoto: (String, String) -> Unit, + onPickDocumentFile: (String, String) -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + "Kroki do wykonania", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + if (steps.isEmpty()) { + Text("Brak dodatkowych czynności. Możesz potwierdzić etap.", color = TppTheme.colors.muted) + } + steps.forEachIndexed { index, step -> + val required = routeStageRequirementIsRequired(step.requirement) + val done = when (step.type) { + RouteWorkflowStepType.Confirmation -> step.id in completedStepIds + RouteWorkflowStepType.Weight -> parseRouteWeightTons(weightText)?.let { it > 0.0 } == true + RouteWorkflowStepType.Photos -> + serverPhotos.any { it.workflowDocumentId.isNullOrBlank() } || + localUploads.any { + it.workflowDocumentId.isNullOrBlank() && + it.status != PhotoUploadStatus.FailedPermanent.storageValue && + it.status != PhotoUploadStatus.Cancelled.storageValue + } + else -> false + } + + Column( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.panel, RoundedCornerShape(8.dp)) + .border(1.dp, TppTheme.colors.outline.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) + .padding(13.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier + .size(30.dp) + .background(if (done) TppTheme.colors.successContainer else TppTheme.colors.card, RoundedCornerShape(15.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + if (done) "✓" else "${index + 1}", + color = if (done) TppTheme.colors.forest else TppTheme.colors.muted, + fontWeight = FontWeight.Bold, + ) + } + Column(Modifier.weight(1f)) { + Text(step.title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) + Text( + if (required) "WYMAGANY" else "OPCJONALNY", + color = if (required) TppTheme.colors.error else TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.labelSmall, + ) + } + if (step.type == RouteWorkflowStepType.Confirmation) { + Checkbox( + checked = step.id in completedStepIds, + onCheckedChange = { onStepToggle(step.id, it) }, + ) + } + } + if (step.description.isNotBlank()) { + Text(step.description, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyMedium) + } + + step.documents.forEach { document -> + val documentServerPhotos = serverPhotos.filter { + it.workflowStepId == step.id && it.workflowDocumentId == document.id + } + val documentUploads = localUploads.filter { + it.workflowStepId == step.id && it.workflowDocumentId == document.id && + it.status != PhotoUploadStatus.Cancelled.storageValue && + it.status != PhotoUploadStatus.FailedPermanent.storageValue + } + val count = documentServerPhotos.size + documentUploads.size + Column( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.card, RoundedCornerShape(6.dp)) + .padding(11.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.Description, contentDescription = null, tint = TppTheme.colors.forest) + Column(Modifier.weight(1f)) { + Text(document.label, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) + Text( + if (count > 0) "$count ${photoCountLabel(count)}" else if (routeStageRequirementIsRequired(document.requirement)) "wymagany dokument" else "opcjonalny dokument", + color = if (count > 0) TppTheme.colors.forest else TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.labelSmall, + ) + } + } + if (document.description.isNotBlank()) { + Text(document.description, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedButton( + onClick = { onTakeDocumentPhoto(step.id, document.id) }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), + ) { + Icon(Icons.Outlined.CameraAlt, contentDescription = null, modifier = Modifier.size(17.dp)) + Spacer(Modifier.width(4.dp)) + Text("Aparat", style = MaterialTheme.typography.labelMedium) + } + OutlinedButton( + onClick = { onPickDocumentPhoto(step.id, document.id) }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), + ) { + Icon(Icons.Outlined.AddPhotoAlternate, contentDescription = null, modifier = Modifier.size(17.dp)) + Spacer(Modifier.width(4.dp)) + Text("Galeria", style = MaterialTheme.typography.labelMedium) + } + OutlinedButton( + onClick = { onPickDocumentFile(step.id, document.id) }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp), + ) { + Icon(Icons.Outlined.Description, contentDescription = null, modifier = Modifier.size(17.dp)) + Spacer(Modifier.width(4.dp)) + Text("Plik", style = MaterialTheme.typography.labelMedium) + } + } + } + } + } + } + } + } +} + @Composable private fun RouteStageSummaryCard(route: DriverRouteDto) { Card( 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 c7765b8..c8eb0ee 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -12,6 +12,8 @@ import java.time.temporal.ChronoUnit import java.util.Locale import kotlin.math.abs import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto +import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.TachographReminderDto @@ -278,6 +280,75 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean = normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required +object RouteWorkflowStepType { + const val Confirmation = "confirmation" + const val Weight = "weight" + const val Photos = "photos" +} + +fun routeWorkflowSteps(workflow: DriverRouteWorkflowDto?, stage: String): List = + when (stage) { + "loading" -> workflow?.loading?.steps + "unloading" -> workflow?.unloading?.steps + else -> null + }.orEmpty().filter { routeStageRequirementIsVisible(it.requirement) } + +fun routeWorkflowSubmitBlocker( + workflow: DriverRouteWorkflowDto?, + stage: String, + completedStepIds: Set, + weightText: String, + serverPhotos: List, + localUploads: List, +): String? { + val steps = routeWorkflowSteps(workflow, stage) + if (steps.isEmpty()) return null + val normalizedWeight = parseRouteWeightTons(weightText) + + for (step in steps) { + val required = routeStageRequirementIsRequired(step.requirement) + when (step.type) { + RouteWorkflowStepType.Confirmation -> if (required && step.id !in completedStepIds) { + return "Potwierdź krok: ${step.title}." + } + RouteWorkflowStepType.Weight -> when { + required && weightText.isBlank() -> return "Uzupełnij krok: ${step.title}." + weightText.isNotBlank() && normalizedWeight == null -> return "Podaj poprawny tonaż." + weightText.isNotBlank() && normalizedWeight != null && normalizedWeight <= 0.0 -> return "Tonaż musi być większy od zera." + weightText.isNotBlank() && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> return "Tonaż nie może przekraczać 999,999 t." + } + RouteWorkflowStepType.Photos -> if ( + required && + serverPhotos.none { it.workflowDocumentId.isNullOrBlank() } && + localUploads.none { + it.workflowDocumentId.isNullOrBlank() && + it.status != PhotoUploadStatus.Cancelled.storageValue && + it.status != PhotoUploadStatus.FailedPermanent.storageValue + } + ) { + return "Dodaj zdjęcie dla kroku: ${step.title}." + } + } + + for (document in step.documents.filter { routeStageRequirementIsRequired(it.requirement) }) { + val serverPresent = serverPhotos.any { + it.workflowStepId == step.id && it.workflowDocumentId == document.id + } + val localPresent = localUploads.any { + it.workflowStepId == step.id && + it.workflowDocumentId == document.id && + it.status != PhotoUploadStatus.Cancelled.storageValue && + it.status != PhotoUploadStatus.FailedPermanent.storageValue + } + if (!serverPresent && !localPresent) { + return "Dodaj dokument „${document.label}” w kroku „${step.title}”." + } + } + } + + return null +} + fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength) fun parseRouteWeightTons(weightText: String): Double? { 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 09a3326..fa7fc2e 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -39,6 +39,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.driverLifecycleStatus @@ -98,6 +99,7 @@ data class DriverUiState( val loadingWeightRequirement: String = RouteStageRequirement.Required, val unloadingPhotoRequirement: String = RouteStageRequirement.Required, val unloadingWeightRequirement: String = RouteStageRequirement.Required, + val routeWorkflow: DriverRouteWorkflowDto? = null, val notifyNewRoutes: Boolean = false, val routeProgressNotificationEnabled: Boolean = true, val notificationPermissionDenied: Boolean = false, @@ -105,6 +107,7 @@ data class DriverUiState( val selectedPhoto: RoutePhotoDto? = null, val routeStageWeightText: String = "", val routeStageNotesText: String = "", + val routeStageCompletedStepIds: Set = emptySet(), val routeActions: List = emptyList(), val visibleRouteActions: List = emptyList(), val photoUploads: List = emptyList(), @@ -613,6 +616,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), unloadingWeightRequirement = normalizeRouteStageRequirement(settings?.unloadingWeightRequirement), + routeWorkflow = settings?.routeWorkflow, leaveRequestsConfig = settings?.leaveRequests?.let { config -> LeaveRequestsConfig(enabled = config.enabled, types = config.types) }, @@ -1483,6 +1487,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other", + workflowStepId: String? = null, + workflowDocumentId: String? = null, ): Job? { val snapshot = _state.value val route = snapshot.selectedRoute ?: return null @@ -1492,7 +1498,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } return viewModelScope.launch { _state.update { it.copy(error = null) } - runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) } + runCatching { + photoUploadOutbox.enqueue( + routeId = route.id, + uri = uri, + source = source, + metadata = metadata, + stage = stage, + workflowVersion = snapshot.routeWorkflow?.version, + workflowStepId = workflowStepId, + workflowDocumentId = workflowDocumentId, + ) + } .onFailure { throwable -> _state.update { it.withApiError(throwable) } } @@ -1510,7 +1527,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } return } - val targetScreen = if ( + val targetScreen = if (snapshot.routeWorkflow != null) { + DriverScreen.StartRoute + } else if ( !routeStageRequirementIsVisible(snapshot.loadingPhotoRequirement) && routeStageRequirementIsVisible(snapshot.loadingWeightRequirement) ) DriverScreen.LoadingWeight else DriverScreen.StartRoute @@ -1519,6 +1538,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) screen = targetScreen, routeStageWeightText = "", routeStageNotesText = route.loadingNotes.orEmpty(), + routeStageCompletedStepIds = emptySet(), feedback = null, error = null, ) @@ -1564,6 +1584,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) screen = DriverScreen.FinishRoute, routeStageWeightText = route.unloadingWeight?.toString().orEmpty(), routeStageNotesText = route.unloadingNotes.orEmpty(), + routeStageCompletedStepIds = emptySet(), feedback = null, error = null, ) @@ -1579,6 +1600,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) } } + fun toggleRouteStageStep(stepId: String, completed: Boolean) { + _state.update { + it.copy( + routeStageCompletedStepIds = if (completed) { + it.routeStageCompletedStepIds + stepId + } else { + it.routeStageCompletedStepIds - stepId + }, + ) + } + } + fun correctFailedRouteAction() { val snapshot = _state.value val action = (snapshot.visibleRouteActions + snapshot.routeActions) @@ -1677,7 +1710,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val stageUploads = routeStageUsableUploads( snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }, ) - val submitBlocker = if (stage == "loading") { + val submitBlocker = if (snapshot.routeWorkflow != null) { + routeWorkflowSubmitBlocker( + workflow = snapshot.routeWorkflow, + stage = stage, + completedStepIds = snapshot.routeStageCompletedStepIds, + weightText = snapshot.routeStageWeightText, + serverPhotos = stagePhotos, + localUploads = stageUploads, + ) + } else if (stage == "loading") { loadingWeightSubmitBlocker( weightText = snapshot.routeStageWeightText, serverPhotoCount = stagePhotos.size, @@ -1700,7 +1742,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) return } - val photoClientRequestIds = if (routeStageRequirementIsVisible(photoRequirement)) { + val photoClientRequestIds = if (snapshot.routeWorkflow != null || routeStageRequirementIsVisible(photoRequirement)) { (stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId }) .map { it.trim() } .filter { it.isNotBlank() } @@ -1719,7 +1761,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) runCatching { val updatedRoute = when (stage) { "loading" -> { - routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds, notes) + routeActionOutbox.enqueueStart( + route.id, + weight, + photoClientRequestIds, + notes, + snapshot.routeWorkflow?.version, + snapshot.routeStageCompletedStepIds.toList(), + ) route.copy( status = "W TRAKCIE", driverStatus = "W TRAKCIE", @@ -1730,7 +1779,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } "unloading" -> { - routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds, notes) + routeActionOutbox.enqueueFinish( + route.id, + weight, + photoClientRequestIds, + notes, + snapshot.routeWorkflow?.version, + snapshot.routeStageCompletedStepIds.toList(), + ) route.copy( status = "ZAKOŃCZONA", driverStatus = "ZAKOŃCZONA", @@ -1751,6 +1807,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, routeStageWeightText = "", routeStageNotesText = "", + routeStageCompletedStepIds = emptySet(), feedback = if (stage == "loading") { if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." } else { 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 2c6596b..3a00ca8 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -6,6 +6,10 @@ 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.DriverRouteStageWorkflowDto +import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDocumentDto +import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto +import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.TachographReminderDto @@ -18,6 +22,52 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType class DriverUiRulesTest { private val today = LocalDate.parse("2026-06-30") + @Test + fun customWorkflowBlocksUntilConfirmationAndRequiredDocumentArePresent() { + val workflow = DriverRouteWorkflowDto( + version = "workflow-v1", + customized = true, + loading = DriverRouteStageWorkflowDto( + steps = listOf( + DriverRouteWorkflowStepDto( + id = "secure-load", + type = RouteWorkflowStepType.Confirmation, + title = "Sprawdź zabezpieczenie ładunku", + requirement = RouteStageRequirement.Required, + documents = listOf( + DriverRouteWorkflowDocumentDto( + id = "cmr", + label = "List przewozowy CMR", + requirement = RouteStageRequirement.Required, + ), + ), + ), + ), + ), + unloading = DriverRouteStageWorkflowDto(), + ) + + assertEquals( + "Potwierdź krok: Sprawdź zabezpieczenie ładunku.", + routeWorkflowSubmitBlocker(workflow, "loading", emptySet(), "", emptyList(), emptyList()), + ) + assertEquals( + "Dodaj dokument „List przewozowy CMR” w kroku „Sprawdź zabezpieczenie ładunku”.", + routeWorkflowSubmitBlocker(workflow, "loading", setOf("secure-load"), "", emptyList(), emptyList()), + ) + assertEquals( + null, + routeWorkflowSubmitBlocker( + workflow, + "loading", + setOf("secure-load"), + "", + listOf(photo("cmr-photo", "loading").copy(workflowStepId = "secure-load", workflowDocumentId = "cmr")), + emptyList(), + ), + ) + } + @Test fun disabledRouteLocationNeverStartsTracking() { assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))