Fix offline route lifecycle projection

This commit is contained in:
admin
2026-07-07 22:29:35 +02:00
parent 13159c576e
commit 8bad547724
10 changed files with 900 additions and 180 deletions
+1 -1
View File
@@ -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"
@@ -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<List<RouteActionEntity>>
@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<List<RouteActionEntity>>
@Query(
"""
UPDATE route_actions
@@ -23,6 +23,9 @@ class RouteActionOutbox(
fun observeUnresolvedForRoute(routeId: String): Flow<List<RouteActionEntity>> =
dao.observeUnresolvedForRoute(routeId)
fun observeVisibleActions(): Flow<List<RouteActionEntity>> =
dao.observeVisibleActions()
suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
@@ -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<Uri?>(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<Uri?>(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<RouteActionEntity>,
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<RouteFlowStepUi>) {
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<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
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<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
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)
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<PhotoDeleteTarget?>(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,
@@ -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<RouteActionEntity>,
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<RouteActionEntity>): 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<RouteActionEntity> { 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<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId },
),
loadingWeightPending = loadingWeightPending,
unloadingWeightPending = unloadingWeightPending,
)
}
fun projectDriverRoutes(routes: List<DriverRouteDto>, actions: List<RouteActionEntity>): List<DriverRouteDto> =
routes.map { route -> projectDriverRoute(route, actions).route }
fun routeActionNeedsAttention(status: String): Boolean =
status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent
@@ -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<RouteActionEntity>): 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<RouteActionEntity>): List<RouteFlowStepUi> {
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<RoutePhotoDto>, stage: String): List<RoutePhotoDto> {
@@ -74,6 +180,28 @@ fun activeRouteUploadIntervalSeconds(
fun activeRouteNotificationTarget(arrived: Boolean): DriverScreen =
if (arrived) DriverScreen.FinishRoute else DriverScreen.Detail
private fun routeActionStepState(actions: List<RouteActionEntity>, 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)
@@ -62,6 +62,7 @@ data class DriverUiState(
val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "",
val routeActions: List<RouteActionEntity> = emptyList(),
val visibleRouteActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = 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<RouteActionEntity>
get() = (visibleRouteActions + routeActions).distinctBy { it.clientActionId }
val displayRoutes: List<DriverRouteDto>
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)
@@ -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<RouteActionEntity>(), 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")
}
}
@@ -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<RoutePhotoDto> = 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 =
@@ -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,
)
}