Fix offline route lifecycle projection
This commit is contained in:
@@ -34,7 +34,7 @@ android {
|
|||||||
applicationId = "pl.firmatpp.kierowca"
|
applicationId = "pl.firmatpp.kierowca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 46
|
versionCode = 47
|
||||||
versionName = "1.0.44"
|
versionName = "1.0.44"
|
||||||
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|||||||
@@ -18,12 +18,21 @@ interface RouteActionDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM route_actions
|
SELECT * FROM route_actions
|
||||||
WHERE routeId = :routeId
|
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
|
ORDER BY createdAtEpochMillis DESC
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
fun observeUnresolvedForRoute(routeId: String): Flow<List<RouteActionEntity>>
|
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(
|
@Query(
|
||||||
"""
|
"""
|
||||||
UPDATE route_actions
|
UPDATE route_actions
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class RouteActionOutbox(
|
|||||||
fun observeUnresolvedForRoute(routeId: String): Flow<List<RouteActionEntity>> =
|
fun observeUnresolvedForRoute(routeId: String): Flow<List<RouteActionEntity>> =
|
||||||
dao.observeUnresolvedForRoute(routeId)
|
dao.observeUnresolvedForRoute(routeId)
|
||||||
|
|
||||||
|
fun observeVisibleActions(): Flow<List<RouteActionEntity>> =
|
||||||
|
dao.observeVisibleActions()
|
||||||
|
|
||||||
suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
|
suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
|
||||||
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
|
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
|
||||||
|
|
||||||
|
|||||||
@@ -252,11 +252,7 @@ fun DriverApp(
|
|||||||
DriverScreen.Detail -> DetailScreen(
|
DriverScreen.Detail -> DetailScreen(
|
||||||
state,
|
state,
|
||||||
viewModel::back,
|
viewModel::back,
|
||||||
{ uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata) },
|
|
||||||
viewModel::openPhoto,
|
viewModel::openPhoto,
|
||||||
viewModel::deletePhoto,
|
|
||||||
viewModel::deleteConfirmedUpload,
|
|
||||||
viewModel::retryPhotoUpload,
|
|
||||||
viewModel::refreshSelectedRoute,
|
viewModel::refreshSelectedRoute,
|
||||||
viewModel::openStartRoute,
|
viewModel::openStartRoute,
|
||||||
viewModel::openFinishRoute,
|
viewModel::openFinishRoute,
|
||||||
@@ -753,10 +749,10 @@ private fun RoutesScreen(
|
|||||||
}
|
}
|
||||||
item { OfflineStaleBanner(state) }
|
item { OfflineStaleBanner(state) }
|
||||||
item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
|
item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
|
||||||
if (state.routes.isEmpty()) {
|
if (state.displayRoutes.isEmpty()) {
|
||||||
item { EmptyState("Brak kursow na wybrany dzien") }
|
item { EmptyState("Brak kursow na wybrany dzien") }
|
||||||
} else {
|
} 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) }
|
item { ErrorText(state.error) }
|
||||||
}
|
}
|
||||||
@@ -1808,7 +1804,7 @@ private fun ProfileScreen(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"Pokazuj postęp aktywnego kursu",
|
"Pasek postępu aktywnego kursu",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = TppTheme.colors.ink,
|
color = TppTheme.colors.ink,
|
||||||
@@ -1945,14 +1941,15 @@ private fun RouteStageScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var cameraUri by remember { mutableStateOf<Uri?>(null) }
|
var cameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
|
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
|
||||||
val route = state.selectedRoute
|
val route = state.displaySelectedRoute
|
||||||
val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty()
|
val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty()
|
||||||
val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
|
val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
|
||||||
val canSubmit = canSubmitRouteStageForm(
|
val submitBlocker = routeStageSubmitBlocker(
|
||||||
weightText = state.routeStageWeightText,
|
weightText = state.routeStageWeightText,
|
||||||
serverPhotoCount = stagePhotos.size,
|
serverPhotoCount = stagePhotos.size,
|
||||||
localUploadCount = stageUploads.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 cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
|
||||||
val capturedUri = cameraUri
|
val capturedUri = cameraUri
|
||||||
@@ -1988,29 +1985,59 @@ private fun RouteStageScreen(
|
|||||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { DetailHeader(onBack, title = title) },
|
topBar = { DetailHeader(onBack, title = title) },
|
||||||
|
bottomBar = {
|
||||||
|
RouteStageSubmitBar(
|
||||||
|
submitLabel = submitLabel,
|
||||||
|
submitBlocker = submitBlocker,
|
||||||
|
onSubmit = onSubmit,
|
||||||
|
)
|
||||||
|
},
|
||||||
containerColor = TppTheme.colors.surface,
|
containerColor = TppTheme.colors.surface,
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize().padding(padding),
|
Modifier.fillMaxSize().padding(padding),
|
||||||
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
|
contentPadding = PaddingValues(start = horizontalPadding, top = 16.dp, end = horizontalPadding, bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(20.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 {
|
item {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.routeStageWeightText,
|
value = state.routeStageWeightText,
|
||||||
onValueChange = onWeightChange,
|
onValueChange = onWeightChange,
|
||||||
label = { Text(weightLabel) },
|
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,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
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) {
|
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) {
|
||||||
val newUri = createCameraUri(context)
|
val newUri = createCameraUri(context)
|
||||||
cameraUri = newUri
|
cameraUri = newUri
|
||||||
@@ -2024,6 +2051,11 @@ private fun RouteStageScreen(
|
|||||||
cameraPermissionLauncher.launch(missingPermissions)
|
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 {
|
item {
|
||||||
@@ -2038,17 +2070,6 @@ private fun RouteStageScreen(
|
|||||||
onRetryUpload = onRetryUpload,
|
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) }
|
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)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailScreen(
|
private fun DetailScreen(
|
||||||
state: DriverUiState,
|
state: DriverUiState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
|
|
||||||
onPhoto: (RoutePhotoDto) -> Unit,
|
onPhoto: (RoutePhotoDto) -> Unit,
|
||||||
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
|
||||||
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
|
||||||
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onStartRoute: () -> Unit,
|
onStartRoute: () -> Unit,
|
||||||
onFinishRoute: () -> Unit,
|
onFinishRoute: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var cameraUri by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
|
|
||||||
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
|
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
|
||||||
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
|
val projection = state.selectedRouteProjection
|
||||||
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)
|
|
||||||
|
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||||
@@ -2142,7 +2271,7 @@ private fun DetailScreen(
|
|||||||
},
|
},
|
||||||
containerColor = TppTheme.colors.surface,
|
containerColor = TppTheme.colors.surface,
|
||||||
) { padding ->
|
) { padding ->
|
||||||
if (route == null) return@Scaffold
|
val route = projection?.route ?: return@Scaffold
|
||||||
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
@@ -2156,7 +2285,9 @@ private fun DetailScreen(
|
|||||||
route = route,
|
route = route,
|
||||||
selectedDate = state.selectedDate,
|
selectedDate = state.selectedDate,
|
||||||
feedback = state.feedback,
|
feedback = state.feedback,
|
||||||
routeActions = state.routeActions,
|
routeActions = projection.visibleActions,
|
||||||
|
routeProgressNotificationEnabled = state.routeProgressNotificationEnabled,
|
||||||
|
onRefresh = onRefresh,
|
||||||
onStart = onStartRoute,
|
onStart = onStartRoute,
|
||||||
onFinish = onFinishRoute,
|
onFinish = onFinishRoute,
|
||||||
)
|
)
|
||||||
@@ -2167,28 +2298,9 @@ private fun DetailScreen(
|
|||||||
uploads = state.photoUploads,
|
uploads = state.photoUploads,
|
||||||
deletingPhotoIds = state.deletingPhotoIds,
|
deletingPhotoIds = state.deletingPhotoIds,
|
||||||
imageAuthHeader = state.imageAuthHeader,
|
imageAuthHeader = state.imageAuthHeader,
|
||||||
allowGalleryUploads = state.allowGalleryUploads,
|
loadingWeightPending = projection.loadingWeightPending,
|
||||||
canManagePhotos = canManagePhotos,
|
unloadingWeightPending = projection.unloadingWeightPending,
|
||||||
onPhoto = onPhoto,
|
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) }
|
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
|
@Composable
|
||||||
@@ -2263,17 +2347,17 @@ private fun RouteLifecycleSection(
|
|||||||
selectedDate: String,
|
selectedDate: String,
|
||||||
feedback: String?,
|
feedback: String?,
|
||||||
routeActions: List<RouteActionEntity>,
|
routeActions: List<RouteActionEntity>,
|
||||||
|
routeProgressNotificationEnabled: Boolean,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
onFinish: () -> Unit,
|
onFinish: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val failedConflict = routeActions.firstOrNull { it.status == "FAILED_CONFLICT" }
|
val steps = routeFlowSteps(route, routeActions)
|
||||||
val pendingAction = routeActions.firstOrNull { it.status in setOf("PENDING", "WAITING_FOR_PHOTOS", "SYNCING", "FAILED_RETRYABLE") }
|
val callout = routeSyncCallout(routeActions)
|
||||||
val canStart = canStartRouteFromDriverApp(route, selectedDate)
|
val action = routeLifecyclePrimaryAction(route, selectedDate)
|
||||||
val canFinish = canFinishRouteFromDriverApp(route, selectedDate)
|
|
||||||
|
|
||||||
if (failedConflict == null && pendingAction == null && feedback.isNullOrBlank() && !canStart && !canFinish) return
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
RouteFlowStepper(steps)
|
||||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
if (!feedback.isNullOrBlank()) {
|
if (!feedback.isNullOrBlank()) {
|
||||||
RouteStateMessage(
|
RouteStateMessage(
|
||||||
text = feedback,
|
text = feedback,
|
||||||
@@ -2284,33 +2368,32 @@ private fun RouteLifecycleSection(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failedConflict != null) {
|
if (callout != null) {
|
||||||
RouteStateMessage(
|
RouteStateMessage(
|
||||||
text = failedConflict.lastError ?: "Synchronizacja kursu jest w konflikcie z backendem. Dane lokalne zostały zachowane.",
|
text = callout.text,
|
||||||
icon = Icons.Outlined.Info,
|
icon = if (callout.isConflict) Icons.Outlined.Info else Icons.Outlined.CloudUpload,
|
||||||
container = TppTheme.colors.warningContainer,
|
container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel,
|
||||||
outline = TppTheme.colors.warningOutline,
|
outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline,
|
||||||
color = TppTheme.colors.error,
|
color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted,
|
||||||
)
|
actionLabel = "Odśwież dane",
|
||||||
} else if (pendingAction != null) {
|
onAction = onRefresh,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
Button(
|
||||||
onClick = if (canStart) onStart else onFinish,
|
onClick = if (action.action == RouteLifecycleAction.Start) onStart else onFinish,
|
||||||
modifier = Modifier.fillMaxWidth().height(64.dp),
|
modifier = Modifier.fillMaxWidth().height(64.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = TppTheme.colors.forest,
|
containerColor = TppTheme.colors.forest,
|
||||||
@@ -2318,13 +2401,13 @@ private fun RouteLifecycleSection(
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
if (canStart) Icons.Outlined.LocalShipping else Icons.Outlined.CheckCircle,
|
if (action.action == RouteLifecycleAction.Start) Icons.Outlined.LocalShipping else Icons.Outlined.CheckCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Text(
|
Text(
|
||||||
if (canStart) "Rozpocznij kurs" else "Zakończ kurs",
|
action.label,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.Bold,
|
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
|
@Composable
|
||||||
private fun RouteStateMessage(
|
private fun RouteStateMessage(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -2339,6 +2507,8 @@ private fun RouteStateMessage(
|
|||||||
container: Color,
|
container: Color,
|
||||||
outline: Color,
|
outline: Color,
|
||||||
color: Color,
|
color: Color,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onAction: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = container),
|
colors = CardDefaults.cardColors(containerColor = container),
|
||||||
@@ -2352,7 +2522,12 @@ private fun RouteStateMessage(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(icon, contentDescription = null, tint = color)
|
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>,
|
uploads: List<PhotoUploadEntity>,
|
||||||
deletingPhotoIds: Set<String>,
|
deletingPhotoIds: Set<String>,
|
||||||
imageAuthHeader: String?,
|
imageAuthHeader: String?,
|
||||||
allowGalleryUploads: Boolean,
|
loadingWeightPending: Boolean,
|
||||||
canManagePhotos: Boolean,
|
unloadingWeightPending: Boolean,
|
||||||
onPhoto: (RoutePhotoDto) -> Unit,
|
onPhoto: (RoutePhotoDto) -> Unit,
|
||||||
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
|
||||||
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
|
||||||
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
|
||||||
onCamera: () -> Unit,
|
|
||||||
onGallery: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val loadingPhotos = routePhotosForStage(route.photos, "loading")
|
val loadingPhotos = routePhotosForStage(route.photos, "loading")
|
||||||
val unloadingPhotos = routePhotosForStage(route.photos, "unloading")
|
val unloadingPhotos = routePhotosForStage(route.photos, "unloading")
|
||||||
@@ -2570,10 +2740,8 @@ private fun CargoDocumentationSection(
|
|||||||
uploads = loadingUploads,
|
uploads = loadingUploads,
|
||||||
deletingPhotoIds = deletingPhotoIds,
|
deletingPhotoIds = deletingPhotoIds,
|
||||||
imageAuthHeader = imageAuthHeader,
|
imageAuthHeader = imageAuthHeader,
|
||||||
|
weightPending = loadingWeightPending,
|
||||||
onPhoto = onPhoto,
|
onPhoto = onPhoto,
|
||||||
onDeletePhoto = onDeletePhoto,
|
|
||||||
onDeleteUpload = onDeleteUpload,
|
|
||||||
onRetryUpload = onRetryUpload,
|
|
||||||
)
|
)
|
||||||
RouteStageDocumentationBlock(
|
RouteStageDocumentationBlock(
|
||||||
title = "Rozładunek",
|
title = "Rozładunek",
|
||||||
@@ -2582,10 +2750,8 @@ private fun CargoDocumentationSection(
|
|||||||
uploads = unloadingUploads,
|
uploads = unloadingUploads,
|
||||||
deletingPhotoIds = deletingPhotoIds,
|
deletingPhotoIds = deletingPhotoIds,
|
||||||
imageAuthHeader = imageAuthHeader,
|
imageAuthHeader = imageAuthHeader,
|
||||||
|
weightPending = unloadingWeightPending,
|
||||||
onPhoto = onPhoto,
|
onPhoto = onPhoto,
|
||||||
onDeletePhoto = onDeletePhoto,
|
|
||||||
onDeleteUpload = onDeleteUpload,
|
|
||||||
onRetryUpload = onRetryUpload,
|
|
||||||
)
|
)
|
||||||
if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) {
|
if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) {
|
||||||
RouteStageDocumentationBlock(
|
RouteStageDocumentationBlock(
|
||||||
@@ -2595,10 +2761,8 @@ private fun CargoDocumentationSection(
|
|||||||
uploads = otherUploads,
|
uploads = otherUploads,
|
||||||
deletingPhotoIds = deletingPhotoIds,
|
deletingPhotoIds = deletingPhotoIds,
|
||||||
imageAuthHeader = imageAuthHeader,
|
imageAuthHeader = imageAuthHeader,
|
||||||
|
weightPending = false,
|
||||||
onPhoto = onPhoto,
|
onPhoto = onPhoto,
|
||||||
onDeletePhoto = onDeletePhoto,
|
|
||||||
onDeleteUpload = onDeleteUpload,
|
|
||||||
onRetryUpload = onRetryUpload,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2612,11 +2776,10 @@ private fun RouteStageDocumentationBlock(
|
|||||||
uploads: List<PhotoUploadEntity>,
|
uploads: List<PhotoUploadEntity>,
|
||||||
deletingPhotoIds: Set<String>,
|
deletingPhotoIds: Set<String>,
|
||||||
imageAuthHeader: String?,
|
imageAuthHeader: String?,
|
||||||
|
weightPending: Boolean = false,
|
||||||
onPhoto: (RoutePhotoDto) -> Unit,
|
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)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -2624,14 +2787,53 @@ private fun RouteStageDocumentationBlock(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
Text(title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
weight?.let { "Waga: ${formatRouteWeight(it)}" } ?: "Waga: -",
|
Text(
|
||||||
color = TppTheme.colors.muted,
|
weight?.let { "Waga: ${formatRouteWeight(it)}" } ?: "Waga: -",
|
||||||
fontFamily = FontFamily.Monospace,
|
color = TppTheme.colors.muted,
|
||||||
fontWeight = FontWeight.Bold,
|
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,
|
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
||||||
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
||||||
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||||
|
readOnly: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var pendingDelete by remember { mutableStateOf<PhotoDeleteTarget?>(null) }
|
var pendingDelete by remember { mutableStateOf<PhotoDeleteTarget?>(null) }
|
||||||
val items = (uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) })
|
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,
|
isDeleting = item.photo.id in deletingPhotoIds,
|
||||||
onPhoto = onPhoto,
|
onPhoto = onPhoto,
|
||||||
onDeletePhoto = { pendingDelete = PhotoDeleteTarget.Server(it) },
|
onDeletePhoto = { pendingDelete = PhotoDeleteTarget.Server(it) },
|
||||||
|
readOnly = readOnly,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
is PhotoGridItem.Upload -> PendingPhotoTile(
|
is PhotoGridItem.Upload -> PendingPhotoTile(
|
||||||
@@ -2709,6 +2913,7 @@ private fun PhotoGrid(
|
|||||||
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
|
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
|
||||||
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
|
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
|
||||||
onRetry = onRetryUpload,
|
onRetry = onRetryUpload,
|
||||||
|
readOnly = readOnly,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2771,6 +2976,7 @@ private fun PhotoTile(
|
|||||||
isDeleting: Boolean,
|
isDeleting: Boolean,
|
||||||
onPhoto: (RoutePhotoDto) -> Unit,
|
onPhoto: (RoutePhotoDto) -> Unit,
|
||||||
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
||||||
|
readOnly: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hasPhoto = photo.url.isNotBlank()
|
val hasPhoto = photo.url.isNotBlank()
|
||||||
@@ -2819,7 +3025,7 @@ private fun PhotoTile(
|
|||||||
Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (photo.canDelete) {
|
if (!readOnly && photo.canDelete) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onDeletePhoto(photo) },
|
onClick = { onDeletePhoto(photo) },
|
||||||
enabled = !isDeleting,
|
enabled = !isDeleting,
|
||||||
@@ -2848,6 +3054,7 @@ private fun PendingPhotoTile(
|
|||||||
onPhoto: (PhotoUploadEntity) -> Unit,
|
onPhoto: (PhotoUploadEntity) -> Unit,
|
||||||
onDelete: (PhotoUploadEntity) -> Unit,
|
onDelete: (PhotoUploadEntity) -> Unit,
|
||||||
onRetry: (PhotoUploadEntity) -> Unit,
|
onRetry: (PhotoUploadEntity) -> Unit,
|
||||||
|
readOnly: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val status = upload.statusType
|
val status = upload.statusType
|
||||||
@@ -2907,7 +3114,7 @@ private fun PendingPhotoTile(
|
|||||||
Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canDeleteServerPhoto) {
|
if (!readOnly && canDeleteServerPhoto) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onDelete(upload) },
|
onClick = { onDelete(upload) },
|
||||||
enabled = !isDeleting,
|
enabled = !isDeleting,
|
||||||
@@ -2945,7 +3152,7 @@ private fun PendingPhotoTile(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canRetryPhotoUpload(upload.status)) {
|
if (!readOnly && canRetryPhotoUpload(upload.status)) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onRetry(upload) },
|
onClick = { onRetry(upload) },
|
||||||
enabled = !isDeleting,
|
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.DispatchSheetReminderDto
|
||||||
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
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")
|
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 =
|
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
|
||||||
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
|
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
|
||||||
|
|
||||||
@@ -41,8 +74,81 @@ fun canFinishRouteFromDriverApp(
|
|||||||
&& route.status == "W TRAKCIE"
|
&& route.status == "W TRAKCIE"
|
||||||
|
|
||||||
fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean {
|
fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean {
|
||||||
val normalizedWeight = weightText.trim().replace(',', '.').toDoubleOrNull() ?: return false
|
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null
|
||||||
return normalizedWeight > 0.0 && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) > 0
|
}
|
||||||
|
|
||||||
|
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> {
|
fun routePhotosForStage(photos: List<RoutePhotoDto>, stage: String): List<RoutePhotoDto> {
|
||||||
@@ -74,6 +180,28 @@ fun activeRouteUploadIntervalSeconds(
|
|||||||
fun activeRouteNotificationTarget(arrived: Boolean): DriverScreen =
|
fun activeRouteNotificationTarget(arrived: Boolean): DriverScreen =
|
||||||
if (arrived) DriverScreen.FinishRoute else DriverScreen.Detail
|
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 =
|
fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
|
||||||
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
|
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ data class DriverUiState(
|
|||||||
val selectedPhoto: RoutePhotoDto? = null,
|
val selectedPhoto: RoutePhotoDto? = null,
|
||||||
val routeStageWeightText: String = "",
|
val routeStageWeightText: String = "",
|
||||||
val routeActions: List<RouteActionEntity> = emptyList(),
|
val routeActions: List<RouteActionEntity> = emptyList(),
|
||||||
|
val visibleRouteActions: List<RouteActionEntity> = emptyList(),
|
||||||
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||||
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
|
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||||
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
|
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
|
||||||
@@ -83,7 +84,19 @@ data class DriverUiState(
|
|||||||
val themeMode: AppThemeMode = AppThemeMode.Default,
|
val themeMode: AppThemeMode = AppThemeMode.Default,
|
||||||
val feedback: String? = null,
|
val feedback: String? = null,
|
||||||
val error: 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 {
|
private fun DriverUiState.withApiError(throwable: Throwable): DriverUiState {
|
||||||
val apiError = ApiErrorMapper.map(throwable)
|
val apiError = ApiErrorMapper.map(throwable)
|
||||||
@@ -146,6 +159,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_state.update { it.copy(queuedPhotoUploads = uploads) }
|
_state.update { it.copy(queuedPhotoUploads = uploads) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
routeActionOutbox.observeVisibleActions().collect { actions ->
|
||||||
|
_state.update { it.copy(visibleRouteActions = actions) }
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkMonitor.isOnline.collect { online ->
|
networkMonitor.isOnline.collect { online ->
|
||||||
liveSyncClient.onNetworkAvailable(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.DispatchSheetReminderDto
|
||||||
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
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 {
|
class DriverUiRulesTest {
|
||||||
private val today = LocalDate.parse("2026-06-30")
|
private val today = LocalDate.parse("2026-06-30")
|
||||||
@@ -285,7 +288,86 @@ class DriverUiRulesTest {
|
|||||||
assertEquals(DriverScreen.FinishRoute, activeRouteNotificationTarget(arrived = true))
|
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(
|
DriverRouteDto(
|
||||||
id = "1",
|
id = "1",
|
||||||
startsAt = "",
|
startsAt = "",
|
||||||
@@ -296,9 +378,24 @@ class DriverUiRulesTest {
|
|||||||
contractCode = "TPP-1",
|
contractCode = "TPP-1",
|
||||||
relationLabel = "Baza -> Instalacja",
|
relationLabel = "Baza -> Instalacja",
|
||||||
status = status,
|
status = status,
|
||||||
|
loadingWeight = loadingWeight,
|
||||||
|
unloadingWeight = unloadingWeight,
|
||||||
distanceKm = 1.0,
|
distanceKm = 1.0,
|
||||||
notes = null,
|
notes = null,
|
||||||
truck = 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 =
|
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.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
|
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
|
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
|
||||||
|
|
||||||
class DriverUiStateTest {
|
class DriverUiStateTest {
|
||||||
@@ -51,6 +55,21 @@ class DriverUiStateTest {
|
|||||||
assertEquals(1, refreshed.leaveRequests.size)
|
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 =
|
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
|
||||||
DriverLeaveRequestDto(
|
DriverLeaveRequestDto(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -59,4 +78,31 @@ class DriverUiStateTest {
|
|||||||
type = "URLOP",
|
type = "URLOP",
|
||||||
status = status,
|
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