Fix offline route lifecycle projection
This commit is contained in:
@@ -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)
|
||||
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<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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user