From 37407e27373678c87032cfea5b011b6c99d55a85 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 7 Jul 2026 22:29:35 +0200 Subject: [PATCH] Fix offline route lifecycle projection --- app/build.gradle.kts | 2 +- .../kierowca/data/upload/RouteActionDao.kt | 11 +- .../kierowca/data/upload/RouteActionOutbox.kt | 3 + .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 555 ++++++++++++------ .../kierowca/ui/DriverRouteProjection.kt | 69 +++ .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 132 ++++- .../firmatpp/kierowca/ui/DriverViewModel.kt | 20 +- .../kierowca/ui/DriverRouteProjectionTest.kt | 143 +++++ .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 99 +++- .../firmatpp/kierowca/ui/DriverUiStateTest.kt | 46 ++ 10 files changed, 900 insertions(+), 180 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e417714..68b7d5a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,7 +34,7 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 46 + versionCode = 47 versionName = "1.0.44" setProperty("archivesBaseName", "pl.firmatpp.kierowca") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt index 177f457..2955970 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt @@ -18,12 +18,21 @@ interface RouteActionDao { """ SELECT * FROM route_actions WHERE routeId = :routeId - AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT') + AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT') ORDER BY createdAtEpochMillis DESC """, ) fun observeUnresolvedForRoute(routeId: String): Flow> + @Query( + """ + SELECT * FROM route_actions + WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT') + ORDER BY createdAtEpochMillis DESC + """, + ) + fun observeVisibleActions(): Flow> + @Query( """ UPDATE route_actions 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 e6307ef..8ed81a0 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 @@ -23,6 +23,9 @@ class RouteActionOutbox( fun observeUnresolvedForRoute(routeId: String): Flow> = dao.observeUnresolvedForRoute(routeId) + fun observeVisibleActions(): Flow> = + dao.observeVisibleActions() + 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/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index ae794d8..8457a45 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -252,11 +252,7 @@ fun DriverApp( DriverScreen.Detail -> DetailScreen( state, viewModel::back, - { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata) }, viewModel::openPhoto, - viewModel::deletePhoto, - viewModel::deleteConfirmedUpload, - viewModel::retryPhotoUpload, viewModel::refreshSelectedRoute, viewModel::openStartRoute, viewModel::openFinishRoute, @@ -753,10 +749,10 @@ private fun RoutesScreen( } item { OfflineStaleBanner(state) } item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) } - if (state.routes.isEmpty()) { + if (state.displayRoutes.isEmpty()) { item { EmptyState("Brak kursow na wybrany dzien") } } else { - items(state.routes, key = { it.id }) { route -> StitchRouteCard(route, onRoute) } + items(state.displayRoutes, key = { it.id }) { route -> StitchRouteCard(route, onRoute) } } item { ErrorText(state.error) } } @@ -1808,7 +1804,7 @@ private fun ProfileScreen( ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( - "Pokazuj postęp aktywnego kursu", + "Pasek postępu aktywnego kursu", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = TppTheme.colors.ink, @@ -1945,14 +1941,15 @@ private fun RouteStageScreen( val context = LocalContext.current var cameraUri by remember { mutableStateOf(null) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } - val route = state.selectedRoute + val route = state.displaySelectedRoute val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty() val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } - val canSubmit = canSubmitRouteStageForm( + val submitBlocker = routeStageSubmitBlocker( weightText = state.routeStageWeightText, serverPhotoCount = stagePhotos.size, localUploadCount = stageUploads.size, ) + val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) } val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val capturedUri = cameraUri @@ -1988,29 +1985,59 @@ private fun RouteStageScreen( val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp Scaffold( topBar = { DetailHeader(onBack, title = title) }, + bottomBar = { + RouteStageSubmitBar( + submitLabel = submitLabel, + submitBlocker = submitBlocker, + onSubmit = onSubmit, + ) + }, containerColor = TppTheme.colors.surface, ) { padding -> LazyColumn( Modifier.fillMaxSize().padding(padding), - contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(start = horizontalPadding, top = 16.dp, end = horizontalPadding, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { + if (route != null) { + item { RouteStageSummaryCard(route) } + } + item { + RouteStageChecklist( + hasValidWeight = routeStageSubmitBlocker( + weightText = state.routeStageWeightText, + serverPhotoCount = 1, + localUploadCount = 0, + ) == null, + photoCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size), + ) + } item { OutlinedTextField( value = state.routeStageWeightText, onValueChange = onWeightChange, label = { Text(weightLabel) }, + trailingIcon = { + Text( + "kg", + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) + }, + supportingText = { + if (weightBlocker != null) { + Text(weightBlocker) + } + }, + isError = weightBlocker != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, modifier = Modifier.fillMaxWidth(), ) } item { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (state.allowGalleryUploads) { - CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { - pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - } - } CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) { val newUri = createCameraUri(context) cameraUri = newUri @@ -2024,6 +2051,11 @@ private fun RouteStageScreen( cameraPermissionLauncher.launch(missingPermissions) } } + if (state.allowGalleryUploads) { + CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { + pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + } } } item { @@ -2038,17 +2070,6 @@ private fun RouteStageScreen( onRetryUpload = onRetryUpload, ) } - item { - Button( - onClick = onSubmit, - enabled = canSubmit, - modifier = Modifier.fillMaxWidth().height(64.dp), - colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), - shape = MaterialTheme.shapes.medium, - ) { - Text(submitLabel, color = Color.White, fontWeight = FontWeight.Bold) - } - } item { ErrorText(state.error) } } } @@ -2083,55 +2104,163 @@ private fun RouteStageScreen( } } +@Composable +private fun RouteStageSummaryCard(route: DriverRouteDto) { + 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(12.dp)) { + Text( + route.relationLabel.ifBlank { "${route.originName} → ${route.destinationName}" }, + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + RouteStageSummaryFact("Zlecenie", route.contractCode ?: route.id, Modifier.weight(1f)) + RouteStageSummaryFact("Pojazd", route.truckLabel(), Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun RouteStageSummaryFact(label: String, value: String, modifier: Modifier = Modifier) { + Column( + modifier + .background(TppTheme.colors.panel, RoundedCornerShape(4.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + label.uppercase(Locale("pl", "PL")), + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + ) + Text( + value.ifBlank { "-" }, + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun RouteStageChecklist(hasValidWeight: Boolean, photoCount: Int) { + 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(12.dp)) { + Text( + "Wymagane przed wysłaniem", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + RouteStageRequirementRow( + label = "Waga", + done = hasValidWeight, + detail = if (hasValidWeight) "uzupełniona" else "wpisz wartość w kg", + ) + RouteStageRequirementRow( + label = "Zdjęcie etapu", + done = photoCount > 0, + detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else "dodaj minimum jedno", + ) + } + } +} + +@Composable +private fun RouteStageRequirementRow(label: String, done: Boolean, detail: String) { + val color = if (done) TppTheme.colors.forest else TppTheme.colors.muted + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(34.dp) + .background(if (done) TppTheme.colors.successContainer else TppTheme.colors.panel, RoundedCornerShape(17.dp)) + .border(1.dp, color.copy(alpha = 0.38f), RoundedCornerShape(17.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + if (done) Icons.Outlined.CheckCircle else Icons.Outlined.Info, + contentDescription = null, + tint = color, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) + Text(detail, color = color, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelMedium) + } + } +} + +@Composable +private fun RouteStageSubmitBar( + submitLabel: String, + submitBlocker: String?, + onSubmit: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.card) + .border(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)) + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (submitBlocker != null) { + Text( + submitBlocker, + color = TppTheme.colors.error, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelMedium, + ) + } + Button( + onClick = onSubmit, + enabled = submitBlocker == null, + modifier = Modifier.fillMaxWidth().height(58.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.medium, + ) { + Text(submitLabel, color = Color.White, fontWeight = FontWeight.Bold) + } + } +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable private fun DetailScreen( state: DriverUiState, onBack: () -> Unit, - onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onPhoto: (RoutePhotoDto) -> Unit, - onDeletePhoto: (RoutePhotoDto) -> Unit, - onDeleteUpload: (PhotoUploadEntity) -> Unit, - onRetryUpload: (PhotoUploadEntity) -> Unit, onRefresh: () -> Unit, onStartRoute: () -> Unit, onFinishRoute: () -> Unit, ) { val context = LocalContext.current - var cameraUri by remember { mutableStateOf(null) } - var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) - val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> - val capturedUri = cameraUri - if ( - ok && - capturedUri != null && - canLaunchCameraWithLocationPolicy( - requirePreciseLocation = state.requirePreciseLocationForPhotos, - hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), - ) - ) { - onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) - } - cameraUri = null - } - val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> - val pendingUri = cameraUri - val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA) - val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || - hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) - if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) { - showPreciseLocationPermissionDialog = true - cameraUri = null - return@rememberLauncherForActivityResult - } - if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri) - } - val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata()) - } - val route = state.selectedRoute - val canManagePhotos = canManageRoutePhotos(state.selectedDate) + val projection = state.selectedRouteProjection BoxWithConstraints(Modifier.fillMaxSize()) { val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp @@ -2142,7 +2271,7 @@ private fun DetailScreen( }, containerColor = TppTheme.colors.surface, ) { padding -> - if (route == null) return@Scaffold + val route = projection?.route ?: return@Scaffold Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) { LazyColumn( Modifier.fillMaxSize(), @@ -2156,7 +2285,9 @@ private fun DetailScreen( route = route, selectedDate = state.selectedDate, feedback = state.feedback, - routeActions = state.routeActions, + routeActions = projection.visibleActions, + routeProgressNotificationEnabled = state.routeProgressNotificationEnabled, + onRefresh = onRefresh, onStart = onStartRoute, onFinish = onFinishRoute, ) @@ -2167,28 +2298,9 @@ private fun DetailScreen( uploads = state.photoUploads, deletingPhotoIds = state.deletingPhotoIds, imageAuthHeader = state.imageAuthHeader, - allowGalleryUploads = state.allowGalleryUploads, - canManagePhotos = canManagePhotos, + loadingWeightPending = projection.loadingWeightPending, + unloadingWeightPending = projection.unloadingWeightPending, onPhoto = onPhoto, - onDeletePhoto = onDeletePhoto, - onDeleteUpload = onDeleteUpload, - onRetryUpload = onRetryUpload, - onCamera = { - val newUri = createCameraUri(context) - cameraUri = newUri - val missingPermissions = cameraCapturePermissions( - context = context, - requirePreciseLocation = state.requirePreciseLocationForPhotos, - ) - if (missingPermissions.isEmpty()) { - cameraLauncher.launch(newUri) - } else { - cameraPermissionLauncher.launch(missingPermissions) - } - }, - onGallery = { - pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - }, ) } item { ErrorText(state.error) } @@ -2203,34 +2315,6 @@ private fun DetailScreen( } } } - - if (showPreciseLocationPermissionDialog) { - AlertDialog( - onDismissRequest = { showPreciseLocationPermissionDialog = false }, - title = { Text("Brak dokładnej lokalizacji", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, - text = { - Text( - "Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.", - color = TppTheme.colors.muted, - ) - }, - confirmButton = { - TextButton( - onClick = { - showPreciseLocationPermissionDialog = false - openAppSettings(context) - }, - ) { - Text("Przejdź do ustawień", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) - } - }, - dismissButton = { - TextButton(onClick = { showPreciseLocationPermissionDialog = false }) { - Text("Anuluj", color = TppTheme.colors.muted) - } - }, - ) - } } @Composable @@ -2263,17 +2347,17 @@ private fun RouteLifecycleSection( selectedDate: String, feedback: String?, routeActions: List, + routeProgressNotificationEnabled: Boolean, + onRefresh: () -> Unit, onStart: () -> Unit, onFinish: () -> Unit, ) { - val failedConflict = routeActions.firstOrNull { it.status == "FAILED_CONFLICT" } - val pendingAction = routeActions.firstOrNull { it.status in setOf("PENDING", "WAITING_FOR_PHOTOS", "SYNCING", "FAILED_RETRYABLE") } - val canStart = canStartRouteFromDriverApp(route, selectedDate) - val canFinish = canFinishRouteFromDriverApp(route, selectedDate) + val steps = routeFlowSteps(route, routeActions) + val callout = routeSyncCallout(routeActions) + val action = routeLifecyclePrimaryAction(route, selectedDate) - if (failedConflict == null && pendingAction == null && feedback.isNullOrBlank() && !canStart && !canFinish) return - - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + RouteFlowStepper(steps) if (!feedback.isNullOrBlank()) { RouteStateMessage( text = feedback, @@ -2284,33 +2368,32 @@ private fun RouteLifecycleSection( ) } - if (failedConflict != null) { + if (callout != null) { RouteStateMessage( - text = failedConflict.lastError ?: "Synchronizacja kursu jest w konflikcie z backendem. Dane lokalne zostały zachowane.", - icon = Icons.Outlined.Info, - container = TppTheme.colors.warningContainer, - outline = TppTheme.colors.warningOutline, - color = TppTheme.colors.error, - ) - } else if (pendingAction != null) { - RouteStateMessage( - text = when (pendingAction.status) { - "WAITING_FOR_PHOTOS" -> "Czekam na wysłanie zdjęć etapu." - "SYNCING" -> "Synchronizuję zmianę statusu kursu." - "FAILED_RETRYABLE" -> "Synchronizacja nie powiodła się, aplikacja spróbuje ponownie." - else -> "Zmiana statusu kursu czeka na synchronizację." - }, - icon = Icons.Outlined.CloudUpload, - container = TppTheme.colors.panel, - outline = TppTheme.colors.outline, - color = TppTheme.colors.muted, + text = callout.text, + icon = if (callout.isConflict) Icons.Outlined.Info else Icons.Outlined.CloudUpload, + container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel, + outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline, + color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted, + actionLabel = "Odśwież dane", + onAction = onRefresh, ) } - if (!canStart && !canFinish) return@Column + if (route.status == "W TRAKCIE" && !routeProgressNotificationEnabled) { + Text( + "Powiadomienie działa w trybie minimalnym.", + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + + if (action == null) return@Column Button( - onClick = if (canStart) onStart else onFinish, + onClick = if (action.action == RouteLifecycleAction.Start) onStart else onFinish, modifier = Modifier.fillMaxWidth().height(64.dp), colors = ButtonDefaults.buttonColors( containerColor = TppTheme.colors.forest, @@ -2318,13 +2401,13 @@ private fun RouteLifecycleSection( shape = MaterialTheme.shapes.medium, ) { Icon( - if (canStart) Icons.Outlined.LocalShipping else Icons.Outlined.CheckCircle, + if (action.action == RouteLifecycleAction.Start) Icons.Outlined.LocalShipping else Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White, ) Spacer(Modifier.width(10.dp)) Text( - if (canStart) "Rozpocznij kurs" else "Zakończ kurs", + action.label, color = Color.White, fontWeight = FontWeight.Bold, ) @@ -2332,6 +2415,91 @@ private fun RouteLifecycleSection( } } +@Composable +private fun RouteFlowStepper(steps: List) { + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, TppTheme.colors.outline), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + "Przebieg kursu", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + steps.forEach { step -> + RouteFlowStepItem(step, Modifier.weight(1f)) + } + } + } + } +} + +@Composable +private fun RouteFlowStepItem(step: RouteFlowStepUi, modifier: Modifier = Modifier) { + val color = routeFlowStepColor(step.state) + Column( + modifier + .heightIn(min = 96.dp) + .background(routeFlowStepBackground(step.state), RoundedCornerShape(4.dp)) + .border(1.dp, color.copy(alpha = 0.42f), RoundedCornerShape(4.dp)) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(routeFlowStepIcon(step.state), contentDescription = null, tint = color, modifier = Modifier.size(21.dp)) + Text( + step.title, + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + step.stateLabel, + color = color, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun routeFlowStepColor(state: RouteFlowStepState): Color = + when (state) { + RouteFlowStepState.Todo -> TppTheme.colors.muted + RouteFlowStepState.LocalComplete -> TppTheme.colors.navy + RouteFlowStepState.Syncing -> TppTheme.colors.containerGreen + RouteFlowStepState.Confirmed -> TppTheme.colors.forest + RouteFlowStepState.NeedsAttention -> TppTheme.colors.error + } + +@Composable +private fun routeFlowStepBackground(state: RouteFlowStepState): Color = + when (state) { + RouteFlowStepState.Todo -> TppTheme.colors.panel.copy(alpha = 0.62f) + RouteFlowStepState.LocalComplete -> Color(0xFFE9F2FF) + RouteFlowStepState.Syncing -> TppTheme.colors.successContainer.copy(alpha = 0.75f) + RouteFlowStepState.Confirmed -> TppTheme.colors.successContainer + RouteFlowStepState.NeedsAttention -> TppTheme.colors.warningContainer + } + +private fun routeFlowStepIcon(state: RouteFlowStepState): ImageVector = + when (state) { + RouteFlowStepState.Todo -> Icons.Outlined.Info + RouteFlowStepState.LocalComplete -> Icons.Outlined.LocalShipping + RouteFlowStepState.Syncing -> Icons.Outlined.CloudUpload + RouteFlowStepState.Confirmed -> Icons.Outlined.CheckCircle + RouteFlowStepState.NeedsAttention -> Icons.Outlined.Info + } + @Composable private fun RouteStateMessage( text: String, @@ -2339,6 +2507,8 @@ private fun RouteStateMessage( container: Color, outline: Color, color: Color, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, ) { Card( colors = CardDefaults.cardColors(containerColor = container), @@ -2352,7 +2522,12 @@ private fun RouteStateMessage( verticalAlignment = Alignment.CenterVertically, ) { Icon(icon, contentDescription = null, tint = color) - Text(text, color = color, fontWeight = FontWeight.Bold) + Text(text, color = color, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + if (actionLabel != null && onAction != null) { + TextButton(onClick = onAction) { + Text(actionLabel, color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) + } + } } } } @@ -2541,14 +2716,9 @@ private fun CargoDocumentationSection( uploads: List, deletingPhotoIds: Set, imageAuthHeader: String?, - allowGalleryUploads: Boolean, - canManagePhotos: Boolean, + loadingWeightPending: Boolean, + unloadingWeightPending: Boolean, onPhoto: (RoutePhotoDto) -> Unit, - onDeletePhoto: (RoutePhotoDto) -> Unit, - onDeleteUpload: (PhotoUploadEntity) -> Unit, - onRetryUpload: (PhotoUploadEntity) -> Unit, - onCamera: () -> Unit, - onGallery: () -> Unit, ) { val loadingPhotos = routePhotosForStage(route.photos, "loading") val unloadingPhotos = routePhotosForStage(route.photos, "unloading") @@ -2570,10 +2740,8 @@ private fun CargoDocumentationSection( uploads = loadingUploads, deletingPhotoIds = deletingPhotoIds, imageAuthHeader = imageAuthHeader, + weightPending = loadingWeightPending, onPhoto = onPhoto, - onDeletePhoto = onDeletePhoto, - onDeleteUpload = onDeleteUpload, - onRetryUpload = onRetryUpload, ) RouteStageDocumentationBlock( title = "Rozładunek", @@ -2582,10 +2750,8 @@ private fun CargoDocumentationSection( uploads = unloadingUploads, deletingPhotoIds = deletingPhotoIds, imageAuthHeader = imageAuthHeader, + weightPending = unloadingWeightPending, onPhoto = onPhoto, - onDeletePhoto = onDeletePhoto, - onDeleteUpload = onDeleteUpload, - onRetryUpload = onRetryUpload, ) if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) { RouteStageDocumentationBlock( @@ -2595,10 +2761,8 @@ private fun CargoDocumentationSection( uploads = otherUploads, deletingPhotoIds = deletingPhotoIds, imageAuthHeader = imageAuthHeader, + weightPending = false, onPhoto = onPhoto, - onDeletePhoto = onDeletePhoto, - onDeleteUpload = onDeleteUpload, - onRetryUpload = onRetryUpload, ) } } @@ -2612,11 +2776,10 @@ private fun RouteStageDocumentationBlock( uploads: List, deletingPhotoIds: Set, imageAuthHeader: String?, + weightPending: Boolean = false, onPhoto: (RoutePhotoDto) -> Unit, - onDeletePhoto: (RoutePhotoDto) -> Unit, - onDeleteUpload: (PhotoUploadEntity) -> Unit, - onRetryUpload: (PhotoUploadEntity) -> Unit, ) { + val count = visiblePhotoAttachmentCount(photos.size, uploads.size) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row( Modifier.fillMaxWidth(), @@ -2624,14 +2787,53 @@ private fun RouteStageDocumentationBlock( verticalAlignment = Alignment.CenterVertically, ) { Text(title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - Text( - weight?.let { "Waga: ${formatRouteWeight(it)}" } ?: "Waga: -", - color = TppTheme.colors.muted, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + weight?.let { "Waga: ${formatRouteWeight(it)}" } ?: "Waga: -", + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) + if (weightPending && weight != null) { + Text( + "czeka na wysłanie", + color = TppTheme.colors.containerGreen, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + if (count == 0) { + Box( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.panel, RoundedCornerShape(4.dp)) + .border(1.dp, TppTheme.colors.outline.copy(alpha = 0.55f), RoundedCornerShape(4.dp)) + .padding(14.dp), + ) { + Text( + "Brak zdjęć ${title.lowercase(Locale("pl", "PL"))}", + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelMedium, + ) + } + } else { + PhotoGrid( + photos = photos, + uploads = uploads, + deletingPhotoIds = deletingPhotoIds, + imageAuthHeader = imageAuthHeader, + onPhoto = onPhoto, + onDeletePhoto = {}, + onDeleteUpload = {}, + onRetryUpload = {}, + readOnly = true, ) } - PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onDeleteUpload, onRetryUpload) } } @@ -2662,6 +2864,7 @@ private fun PhotoGrid( onDeletePhoto: (RoutePhotoDto) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit, + readOnly: Boolean = false, ) { var pendingDelete by remember { mutableStateOf(null) } val items = (uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) }) @@ -2701,6 +2904,7 @@ private fun PhotoGrid( isDeleting = item.photo.id in deletingPhotoIds, onPhoto = onPhoto, onDeletePhoto = { pendingDelete = PhotoDeleteTarget.Server(it) }, + readOnly = readOnly, modifier = Modifier.weight(1f), ) is PhotoGridItem.Upload -> PendingPhotoTile( @@ -2709,6 +2913,7 @@ private fun PhotoGrid( onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) }, onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) }, onRetry = onRetryUpload, + readOnly = readOnly, modifier = Modifier.weight(1f), ) } @@ -2771,6 +2976,7 @@ private fun PhotoTile( isDeleting: Boolean, onPhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit, + readOnly: Boolean = false, modifier: Modifier = Modifier, ) { val hasPhoto = photo.url.isNotBlank() @@ -2819,7 +3025,7 @@ private fun PhotoTile( Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) } } - if (photo.canDelete) { + if (!readOnly && photo.canDelete) { IconButton( onClick = { onDeletePhoto(photo) }, enabled = !isDeleting, @@ -2848,6 +3054,7 @@ private fun PendingPhotoTile( onPhoto: (PhotoUploadEntity) -> Unit, onDelete: (PhotoUploadEntity) -> Unit, onRetry: (PhotoUploadEntity) -> Unit, + readOnly: Boolean = false, modifier: Modifier = Modifier, ) { val status = upload.statusType @@ -2907,7 +3114,7 @@ private fun PendingPhotoTile( Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) } } - if (canDeleteServerPhoto) { + if (!readOnly && canDeleteServerPhoto) { IconButton( onClick = { onDelete(upload) }, enabled = !isDeleting, @@ -2945,7 +3152,7 @@ private fun PendingPhotoTile( ) } } - if (canRetryPhotoUpload(upload.status)) { + if (!readOnly && canRetryPhotoUpload(upload.status)) { IconButton( onClick = { onRetry(upload) }, enabled = !isDeleting, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt new file mode 100644 index 0000000..1539849 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt @@ -0,0 +1,69 @@ +package pl.firmatpp.kierowca.ui + +import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.upload.RouteActionEntity +import pl.firmatpp.kierowca.data.upload.RouteActionStatus +import pl.firmatpp.kierowca.data.upload.RouteActionType + +data class ProjectedDriverRoute( + val route: DriverRouteDto, + val visibleActions: List, + val loadingWeightPending: Boolean, + val unloadingWeightPending: Boolean, +) + +internal val routeActionVisibleStatuses = setOf( + RouteActionStatus.Pending, + RouteActionStatus.WaitingForPhotos, + RouteActionStatus.Syncing, + RouteActionStatus.FailedRetryable, + RouteActionStatus.FailedConflict, + RouteActionStatus.FailedPermanent, +) + +fun projectDriverRoute(route: DriverRouteDto, actions: List): ProjectedDriverRoute { + val visible = actions + .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } + var projectedRoute = route + var loadingWeightPending = false + var unloadingWeightPending = false + + visible + .sortedWith(compareBy { it.createdAtEpochMillis }.thenBy { it.clientActionId }) + .forEach { action -> + when (action.action) { + RouteActionType.Start -> { + projectedRoute = projectedRoute.copy( + status = "W TRAKCIE", + loadingWeight = action.weight, + trackingStatus = "active", + ) + loadingWeightPending = true + } + RouteActionType.Finish -> { + projectedRoute = projectedRoute.copy( + status = "ZAKOŃCZONA", + unloadingWeight = action.weight, + trackingStatus = "finished", + completedAt = action.occurredAt, + ) + unloadingWeightPending = true + } + } + } + + return ProjectedDriverRoute( + route = projectedRoute, + visibleActions = visible.sortedWith( + compareByDescending { it.createdAtEpochMillis }.thenBy { it.clientActionId }, + ), + loadingWeightPending = loadingWeightPending, + unloadingWeightPending = unloadingWeightPending, + ) +} + +fun projectDriverRoutes(routes: List, actions: List): List = + routes.map { route -> projectDriverRoute(route, actions).route } + +fun routeActionNeedsAttention(status: String): Boolean = + status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent 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 3eb6108..d2b1043 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -10,9 +10,42 @@ 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.upload.PhotoUploadEntity +import pl.firmatpp.kierowca.data.upload.RouteActionEntity +import pl.firmatpp.kierowca.data.upload.RouteActionStatus +import pl.firmatpp.kierowca.data.upload.RouteActionType private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") +enum class RouteFlowStepState { + Todo, + LocalComplete, + Syncing, + Confirmed, + NeedsAttention, +} + +enum class RouteLifecycleAction { + Start, + Finish, +} + +data class RouteFlowStepUi( + val key: String, + val title: String, + val state: RouteFlowStepState, + val stateLabel: String, +) + +data class RouteLifecycleActionUi( + val label: String, + val action: RouteLifecycleAction, +) + +data class RouteSyncCalloutUi( + val text: String, + val isConflict: Boolean, +) + fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false) @@ -41,8 +74,81 @@ fun canFinishRouteFromDriverApp( && route.status == "W TRAKCIE" fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean { - val normalizedWeight = weightText.trim().replace(',', '.').toDoubleOrNull() ?: return false - return normalizedWeight > 0.0 && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) > 0 + return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null +} + +fun routeStageSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? { + val trimmed = weightText.trim() + val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() + + return when { + trimmed.isBlank() -> "Podaj wagę." + normalizedWeight == null -> "Podaj poprawną wagę." + normalizedWeight <= 0.0 -> "Waga musi być większa od zera." + visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu." + else -> null + } +} + +fun routeLifecyclePrimaryAction( + route: DriverRouteDto, + selectedDate: String, + today: LocalDate = LocalDate.now(), +): RouteLifecycleActionUi? = + when { + canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozpocznij kurs", RouteLifecycleAction.Start) + canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Zakończ kurs", RouteLifecycleAction.Finish) + else -> null + } + +fun routeSyncCallout(actions: List): RouteSyncCalloutUi? { + val unresolved = actions + .filter { it.status in routeActionVisibleStatuses } + .maxByOrNull { it.createdAtEpochMillis } + ?: return null + val isConflict = routeActionNeedsAttention(unresolved.status) + + return RouteSyncCalloutUi( + text = if (isConflict) { + "Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane." + } else { + when (unresolved.status) { + RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu." + RouteActionStatus.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie." + else -> "Zmiana zapisana w telefonie. Wyślemy ją po odzyskaniu internetu." + } + }, + isConflict = isConflict, + ) +} + +fun routeFlowSteps(route: DriverRouteDto, actions: List): List { + val startOverride = routeActionStepState(actions, RouteActionType.Start) + val finishOverride = routeActionStepState(actions, RouteActionType.Finish) + val loadingState = startOverride ?: when { + route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed + route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed + route.loadingWeight != null -> RouteFlowStepState.LocalComplete + 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 + else -> RouteFlowStepState.Todo + } + val unloadingState = finishOverride ?: when { + route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed + route.unloadingWeight != null -> RouteFlowStepState.LocalComplete + else -> RouteFlowStepState.Todo + } + + return listOf( + RouteFlowStepUi("loading", "Załadunek", loadingState, routeFlowStateLabel(loadingState)), + RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)), + RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState)), + ) } fun routePhotosForStage(photos: List, stage: String): List { @@ -74,6 +180,28 @@ fun activeRouteUploadIntervalSeconds( fun activeRouteNotificationTarget(arrived: Boolean): DriverScreen = if (arrived) DriverScreen.FinishRoute else DriverScreen.Detail +private fun routeActionStepState(actions: List, actionType: String): RouteFlowStepState? { + val action = actions + .filter { it.action == actionType && it.status in routeActionVisibleStatuses } + .maxByOrNull { it.createdAtEpochMillis } + ?: return null + + return when (action.status) { + RouteActionStatus.FailedConflict, + RouteActionStatus.FailedPermanent -> RouteFlowStepState.NeedsAttention + else -> RouteFlowStepState.Syncing + } +} + +private fun routeFlowStateLabel(state: RouteFlowStepState): String = + when (state) { + RouteFlowStepState.Todo -> "do zrobienia" + RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie" + RouteFlowStepState.Syncing -> "synchronizacja" + RouteFlowStepState.Confirmed -> "potwierdzone" + RouteFlowStepState.NeedsAttention -> "wymaga uwagi" + } + fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean = viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate) 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 526c940..bb1f0d6 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -62,6 +62,7 @@ data class DriverUiState( val selectedPhoto: RoutePhotoDto? = null, val routeStageWeightText: String = "", val routeActions: List = emptyList(), + val visibleRouteActions: List = emptyList(), val photoUploads: List = emptyList(), val queuedPhotoUploads: List = emptyList(), val dispatchSheetReminder: DispatchSheetReminderDto? = null, @@ -83,7 +84,19 @@ data class DriverUiState( val themeMode: AppThemeMode = AppThemeMode.Default, val feedback: String? = null, val error: String? = null, -) +) { + private val projectionActions: List + get() = (visibleRouteActions + routeActions).distinctBy { it.clientActionId } + + val displayRoutes: List + get() = projectDriverRoutes(routes, projectionActions) + + val selectedRouteProjection: ProjectedDriverRoute? + get() = selectedRoute?.let { projectDriverRoute(it, projectionActions) } + + val displaySelectedRoute: DriverRouteDto? + get() = selectedRouteProjection?.route +} private fun DriverUiState.withApiError(throwable: Throwable): DriverUiState { val apiError = ApiErrorMapper.map(throwable) @@ -146,6 +159,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(queuedPhotoUploads = uploads) } } } + viewModelScope.launch { + routeActionOutbox.observeVisibleActions().collect { actions -> + _state.update { it.copy(visibleRouteActions = actions) } + } + } viewModelScope.launch { networkMonitor.isOnline.collect { online -> liveSyncClient.onNetworkAvailable(online) diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt new file mode 100644 index 0000000..caffe12 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt @@ -0,0 +1,143 @@ +package pl.firmatpp.kierowca.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.upload.RouteActionEntity +import pl.firmatpp.kierowca.data.upload.RouteActionStatus +import pl.firmatpp.kierowca.data.upload.RouteActionType + +class DriverRouteProjectionTest { + @Test + fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() { + val projection = projectDriverRoute( + route = route(status = "ZAPLANOWANA"), + actions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)), + ) + + assertEquals("W TRAKCIE", projection.route.status) + assertEquals(12.5, projection.route.loadingWeight) + assertEquals("active", projection.route.trackingStatus) + assertTrue(projection.loadingWeightPending) + assertFalse(projection.unloadingWeightPending) + assertEquals(RouteLifecycleAction.Finish, routeLifecyclePrimaryAction(projection.route, "2026-06-30", today)?.action) + } + + @Test + fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() { + val projection = projectDriverRoute( + route = route(status = "W TRAKCIE", loadingWeight = 12.5), + actions = listOf(action(RouteActionType.Finish, RouteActionStatus.Syncing, weight = 11.8, occurredAt = "2026-06-30T09:10:00Z")), + ) + + assertEquals("ZAKOŃCZONA", projection.route.status) + assertEquals(11.8, projection.route.unloadingWeight) + assertEquals("finished", projection.route.trackingStatus) + assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt) + assertTrue(projection.unloadingWeightPending) + assertNull(routeLifecyclePrimaryAction(projection.route, "2026-06-30", today)) + } + + @Test + fun startAndFinishAreAppliedChronologicallySoFinishWins() { + val projection = projectDriverRoute( + route = route(status = "ZAPLANOWANA"), + actions = listOf( + action(RouteActionType.Finish, RouteActionStatus.Pending, weight = 11.8, createdAt = 2000), + action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5, createdAt = 1000), + ), + ) + + assertEquals("ZAKOŃCZONA", projection.route.status) + assertEquals(12.5, projection.route.loadingWeight) + assertEquals(11.8, projection.route.unloadingWeight) + assertEquals("finished", projection.route.trackingStatus) + } + + @Test + fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { + val projection = projectDriverRoute( + route = route(status = "W TRAKCIE", loadingWeight = 12.5), + actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)), + ) + val steps = routeFlowSteps(projection.route, projection.visibleActions) + val callout = routeSyncCallout(projection.visibleActions) + + assertEquals("ZAKOŃCZONA", projection.route.status) + assertEquals(11.8, projection.route.unloadingWeight) + assertEquals(RouteFlowStepState.NeedsAttention, steps[2].state) + assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) + assertTrue(callout?.isConflict ?: false) + } + + @Test + fun failedPermanentIsVisibleAndNeedsAttention() { + val projection = projectDriverRoute( + route = route(status = "ZAPLANOWANA"), + actions = listOf(action(RouteActionType.Start, RouteActionStatus.FailedPermanent, weight = 12.5)), + ) + val steps = routeFlowSteps(projection.route, projection.visibleActions) + + assertEquals("W TRAKCIE", projection.route.status) + assertEquals(12.5, projection.route.loadingWeight) + assertEquals(RouteFlowStepState.NeedsAttention, steps[0].state) + } + + @Test + fun noLocalActionsLeavesRouteUnchanged() { + val route = route(status = "ZAPLANOWANA") + val projection = projectDriverRoute(route, emptyList()) + + assertEquals(route, projection.route) + assertFalse(projection.loadingWeightPending) + assertFalse(projection.unloadingWeightPending) + assertEquals(emptyList(), projection.visibleActions) + } + + private fun route( + status: String, + loadingWeight: Double? = null, + unloadingWeight: Double? = null, + ): DriverRouteDto = + DriverRouteDto( + id = "1", + startsAt = "", + originName = "Baza", + destinationName = "Instalacja", + contractorName = "TPP", + contractName = "Kontrakt", + contractCode = "TPP-1", + relationLabel = "Baza -> Instalacja", + status = status, + loadingWeight = loadingWeight, + unloadingWeight = unloadingWeight, + distanceKm = 1.0, + notes = null, + truck = null, + ) + + private fun action( + type: String, + status: String, + weight: Double, + occurredAt: String = "2026-06-30T07:40:00Z", + createdAt: Long = 1000, + ): RouteActionEntity = + RouteActionEntity( + clientActionId = "action-$type-$status-$createdAt", + routeId = "1", + action = type, + weight = weight, + occurredAt = occurredAt, + photoClientRequestIdsJson = "[]", + status = status, + createdAtEpochMillis = createdAt, + ) + + private companion object { + val today = java.time.LocalDate.parse("2026-06-30") + } +} 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 86efd84..e2ba269 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -9,6 +9,9 @@ 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.upload.PhotoUploadEntity +import pl.firmatpp.kierowca.data.upload.RouteActionEntity +import pl.firmatpp.kierowca.data.upload.RouteActionStatus +import pl.firmatpp.kierowca.data.upload.RouteActionType class DriverUiRulesTest { private val today = LocalDate.parse("2026-06-30") @@ -285,7 +288,86 @@ class DriverUiRulesTest { assertEquals(DriverScreen.FinishRoute, activeRouteNotificationTarget(arrived = true)) } - private fun route(status: String): DriverRouteDto = + @Test + fun plannedRouteShowsLoadingAsNextStepAndStartAction() { + val route = route(status = "ZAPLANOWANA") + + val steps = routeFlowSteps(route, emptyList()) + val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) + + assertEquals(listOf("Załadunek", "W trasie", "Rozładunek"), steps.map { it.title }) + assertEquals(RouteFlowStepState.Todo, steps[0].state) + assertEquals(RouteFlowStepState.Todo, steps[1].state) + assertEquals(RouteFlowStepState.Todo, steps[2].state) + assertEquals("Rozpocznij kurs", action?.label) + assertEquals(RouteLifecycleAction.Start, action?.action) + } + + @Test + fun activeRouteShowsTransitStateAndFinishAction() { + val route = route(status = "W TRAKCIE", loadingWeight = 12.5) + + val steps = routeFlowSteps(route, emptyList()) + val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) + + assertEquals(RouteFlowStepState.Confirmed, steps[0].state) + assertEquals(RouteFlowStepState.LocalComplete, steps[1].state) + assertEquals(RouteFlowStepState.Todo, steps[2].state) + assertEquals("Zakończ kurs", action?.label) + assertEquals(RouteLifecycleAction.Finish, action?.action) + } + + @Test + fun finishedRouteShowsCompletedStagesAndNoPrimaryAction() { + val route = route(status = "ZAKOŃCZONA", loadingWeight = 12.5, unloadingWeight = 11.8) + + val steps = routeFlowSteps(route, emptyList()) + val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) + + assertEquals(RouteFlowStepState.Confirmed, steps[0].state) + assertEquals(RouteFlowStepState.Confirmed, steps[1].state) + assertEquals(RouteFlowStepState.Confirmed, steps[2].state) + assertEquals(null, action) + } + + @Test + fun pendingLocalRouteActionShowsSyncingCallout() { + val actions = listOf(action(RouteActionType.Start, RouteActionStatus.WaitingForPhotos)) + + val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions) + val callout = routeSyncCallout(actions) + + assertEquals(RouteFlowStepState.Syncing, steps[0].state) + assertEquals("Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu.", callout?.text) + assertFalse(callout?.isConflict ?: true) + } + + @Test + fun failedConflictRouteActionRequiresAttention() { + val actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, "Kurs został przepisany.")) + + val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions) + val callout = routeSyncCallout(actions) + + assertEquals(RouteFlowStepState.NeedsAttention, steps[2].state) + assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) + assertTrue(callout?.isConflict ?: false) + } + + @Test + fun routeStageSubmitBlockerExplainsMissingRequirements() { + assertEquals("Podaj wagę.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0)) + assertEquals("Waga musi być większa od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0)) + assertEquals("Dodaj co najmniej jedno zdjęcie etapu.", routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0)) + assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1)) + } + + private fun route( + status: String, + loadingWeight: Double? = null, + unloadingWeight: Double? = null, + photos: List = emptyList(), + ): DriverRouteDto = DriverRouteDto( id = "1", startsAt = "", @@ -296,9 +378,24 @@ class DriverUiRulesTest { contractCode = "TPP-1", relationLabel = "Baza -> Instalacja", status = status, + loadingWeight = loadingWeight, + unloadingWeight = unloadingWeight, distanceKm = 1.0, notes = null, truck = null, + photos = photos, + ) + + private fun action(type: String, status: String, lastError: String? = null): RouteActionEntity = + RouteActionEntity( + clientActionId = "action-$type-$status", + routeId = "1", + action = type, + weight = 12.5, + occurredAt = "2026-06-30T07:40:00Z", + photoClientRequestIdsJson = "[]", + status = status, + lastError = lastError, ) private fun upload(status: String, serverPhotoId: String? = null): PhotoUploadEntity = diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt index 5c4a180..85d7806 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiStateTest.kt @@ -4,6 +4,10 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto +import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.upload.RouteActionEntity +import pl.firmatpp.kierowca.data.upload.RouteActionStatus +import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.ui.theme.AppThemeMode class DriverUiStateTest { @@ -51,6 +55,21 @@ class DriverUiStateTest { assertEquals(1, refreshed.leaveRequests.size) } + @Test + fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() { + val route = route(status = "ZAPLANOWANA") + val state = DriverUiState( + routes = listOf(route), + selectedRoute = route, + visibleRouteActions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)), + ) + + assertEquals("W TRAKCIE", state.displayRoutes.single().status) + assertEquals(12.5, state.displayRoutes.single().loadingWeight) + assertEquals("W TRAKCIE", state.displaySelectedRoute?.status) + assertEquals(12.5, state.displaySelectedRoute?.loadingWeight) + } + private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto = DriverLeaveRequestDto( id = id, @@ -59,4 +78,31 @@ class DriverUiStateTest { type = "URLOP", status = status, ) + + private fun route(status: String): DriverRouteDto = + DriverRouteDto( + id = "1", + startsAt = "", + originName = "Baza", + destinationName = "Instalacja", + contractorName = "TPP", + contractName = "Kontrakt", + contractCode = "TPP-1", + relationLabel = "Baza -> Instalacja", + status = status, + distanceKm = 1.0, + notes = null, + truck = null, + ) + + private fun action(type: String, status: String, weight: Double): RouteActionEntity = + RouteActionEntity( + clientActionId = "action-$type-$status", + routeId = "1", + action = type, + weight = weight, + occurredAt = "2026-06-30T07:40:00Z", + photoClientRequestIdsJson = "[]", + status = status, + ) }