Compare commits

...
16 changed files with 703 additions and 45 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 130 versionCode = 133
versionName = "1.0.77" versionName = "1.0.80"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -202,6 +202,9 @@ class DriverRepository(
source: String, source: String,
stage: String = "other", stage: String = "other",
metadata: PhotoUploadMetadata = PhotoUploadMetadata(), metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadResponse { ): PhotoUploadResponse {
val resolver = context.contentResolver val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg" val mimeType = resolver.getType(uri) ?: "image/jpeg"
@@ -222,6 +225,9 @@ class DriverRepository(
contentSha256.toPlainTextBody(), contentSha256.toPlainTextBody(),
sourceBody, sourceBody,
stage.toPlainTextBody(), stage.toPlainTextBody(),
workflowVersion?.toPlainTextBody(),
workflowStepId?.toPlainTextBody(),
workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -249,6 +255,9 @@ class DriverRepository(
upload.contentSha256.toPlainTextBody(), upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(), upload.source.toPlainTextBody(),
upload.stage.toPlainTextBody(), upload.stage.toPlainTextBody(),
upload.workflowVersion?.toPlainTextBody(),
upload.workflowStepId?.toPlainTextBody(),
upload.workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -1,5 +1,6 @@
package pl.firmatpp.kierowca.data package pl.firmatpp.kierowca.data
import java.io.Serializable
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@@ -9,7 +10,7 @@ data class PhotoUploadMetadata(
val latitude: Double? = null, val latitude: Double? = null,
val longitude: Double? = null, val longitude: Double? = null,
val locationAccuracyMeters: Double? = null, val locationAccuracyMeters: Double? = null,
) ) : Serializable
fun PhotoUploadMetadata.toMultipartTextParts(): Map<String, RequestBody> = buildMap { fun PhotoUploadMetadata.toMultipartTextParts(): Map<String, RequestBody> = buildMap {
takenAt?.let { put("takenAt", it.toPlainTextBody()) } takenAt?.let { put("takenAt", it.toPlainTextBody()) }
@@ -216,6 +216,9 @@ interface MobileDriverApi {
@Part("contentSha256") contentSha256: RequestBody, @Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody, @Part("source") source: RequestBody,
@Part("stage") stage: RequestBody?, @Part("stage") stage: RequestBody?,
@Part("workflowVersion") workflowVersion: RequestBody?,
@Part("workflowStepId") workflowStepId: RequestBody?,
@Part("workflowDocumentId") workflowDocumentId: RequestBody?,
@Part("takenAt") takenAt: RequestBody?, @Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?, @Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?, @Part("longitude") longitude: RequestBody?,
@@ -78,6 +78,7 @@ data class DriverAppSettingsDto(
val loadingWeightRequirement: String? = null, val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null, val unloadingPhotoRequirement: String? = null,
val unloadingWeightRequirement: String? = null, val unloadingWeightRequirement: String? = null,
val routeWorkflow: DriverRouteWorkflowDto? = null,
val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null,
@@ -85,6 +86,33 @@ data class DriverAppSettingsDto(
val leaveRequests: LeaveRequestsConfigDto? = null, val leaveRequests: LeaveRequestsConfigDto? = null,
) )
data class DriverRouteWorkflowDto(
val version: String,
val customized: Boolean = false,
val loading: DriverRouteStageWorkflowDto,
val unloading: DriverRouteStageWorkflowDto,
)
data class DriverRouteStageWorkflowDto(
val steps: List<DriverRouteWorkflowStepDto> = emptyList(),
)
data class DriverRouteWorkflowStepDto(
val id: String,
val type: String,
val title: String,
val description: String = "",
val requirement: String = "required",
val documents: List<DriverRouteWorkflowDocumentDto> = emptyList(),
)
data class DriverRouteWorkflowDocumentDto(
val id: String,
val label: String,
val description: String = "",
val requirement: String = "required",
)
data class LeaveRequestsConfigDto( data class LeaveRequestsConfigDto(
val enabled: Boolean = false, val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"), val types: List<String> = listOf("URLOP"),
@@ -522,6 +550,10 @@ data class RoutePhotoDto(
val contentSha256: String? = null, val contentSha256: String? = null,
val source: String, val source: String,
val stage: String = "other", val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val originalName: String? = null,
val mimeType: String?, val mimeType: String?,
val size: Long, val size: Long,
val url: String, val url: String,
@@ -552,6 +584,8 @@ data class StartRouteBody(
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val loadingNotes: String? = null, val loadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
) )
data class FinishRouteBody( data class FinishRouteBody(
@@ -560,6 +594,8 @@ data class FinishRouteBody(
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null, val unloadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
) )
data class RouteActionResponse( data class RouteActionResponse(
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 7, version = 8,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
@@ -208,6 +208,16 @@ abstract class DriverDatabase : RoomDatabase() {
} }
} }
private val migration7To8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowStepId TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowDocumentId TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN completedStepIdsJson TEXT NOT NULL DEFAULT '[]'")
}
}
fun get(context: Context): DriverDatabase = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
@@ -221,6 +231,7 @@ abstract class DriverDatabase : RoomDatabase() {
migration4To5, migration4To5,
migration5To6, migration5To6,
migration6To7, migration6To7,
migration7To8,
).build().also { instance = it } ).build().also { instance = it }
} }
} }
@@ -17,6 +17,9 @@ data class PhotoUploadEntity(
val localPath: String, val localPath: String,
val source: String, val source: String,
val stage: String = "other", val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val takenAt: String?, val takenAt: String?,
val latitude: Double?, val latitude: Double?,
val longitude: Double?, val longitude: Double?,
@@ -37,12 +37,16 @@ class PhotoUploadOutbox(
source: String, source: String,
metadata: PhotoUploadMetadata, metadata: PhotoUploadMetadata,
stage: String = "other", stage: String = "other",
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadEntity = withContext(Dispatchers.IO) { ): PhotoUploadEntity = withContext(Dispatchers.IO) {
val clientRequestId = UUID.randomUUID().toString() val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) { val extension = when (mimeType) {
"image/png" -> "png" "image/png" -> "png"
"image/webp" -> "webp" "image/webp" -> "webp"
"application/pdf" -> "pdf"
else -> "jpg" else -> "jpg"
} }
val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() } val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() }
@@ -54,6 +58,9 @@ class PhotoUploadOutbox(
localPath = localFile.absolutePath, localPath = localFile.absolutePath,
source = source, source = source,
stage = stage, stage = stage,
workflowVersion = workflowVersion,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
takenAt = metadata.takenAt, takenAt = metadata.takenAt,
latitude = metadata.latitude, latitude = metadata.latitude,
longitude = metadata.longitude, longitude = metadata.longitude,
@@ -33,6 +33,8 @@ data class RouteActionEntity(
val weight: Double?, val weight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIdsJson: String, val photoClientRequestIdsJson: String,
val workflowVersion: String? = null,
val completedStepIdsJson: String = "[]",
val status: String = RouteActionStatus.Pending, val status: String = RouteActionStatus.Pending,
val attemptCount: Int = 0, val attemptCount: Int = 0,
val lastError: String? = null, val lastError: String? = null,
@@ -48,16 +48,20 @@ class RouteActionOutbox(
loadingWeight: Double?, loadingWeight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String? = null, notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity = ): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes) enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
suspend fun enqueueFinish( suspend fun enqueueFinish(
routeId: String, routeId: String,
unloadingWeight: Double?, unloadingWeight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String? = null, notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity = ): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes) enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
private suspend fun enqueue( private suspend fun enqueue(
action: String, action: String,
@@ -65,6 +69,8 @@ class RouteActionOutbox(
weight: Double?, weight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String?, notes: String?,
workflowVersion: String?,
completedStepIds: List<String>,
): RouteActionEntity { ): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity( val entity = RouteActionEntity(
@@ -74,6 +80,8 @@ class RouteActionOutbox(
weight = weight, weight = weight,
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
workflowVersion = workflowVersion,
completedStepIdsJson = gson.toJson(completedStepIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() }, notes = notes?.trim()?.takeIf { it.isNotEmpty() },
) )
@@ -181,6 +181,8 @@ class RouteActionWorker(
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes, loadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
), ),
) )
RouteActionType.Finish -> repository.finishRoute( RouteActionType.Finish -> repository.finishRoute(
@@ -191,6 +193,8 @@ class RouteActionWorker(
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes, unloadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
), ),
) )
else -> error("Nieznana akcja kursu: ${action.action}") else -> error("Nieznana akcja kursu: ${action.action}")
@@ -204,6 +208,14 @@ class RouteActionWorker(
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
private fun completedStepIds(action: RouteActionEntity): List<String> =
runCatching {
gson.fromJson(action.completedStepIdsJson, Array<String>::class.java)?.toList().orEmpty()
}.getOrDefault(emptyList())
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
companion object { companion object {
const val KEY_CLIENT_ACTION_ID = "clientActionId" const val KEY_CLIENT_ACTION_ID = "clientActionId"
@@ -115,6 +115,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -166,6 +167,7 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
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.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.NavigationPointDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.TachographReminderDto
@@ -480,23 +482,24 @@ fun DriverApp(
) )
DriverScreen.StartRoute -> RouteStageScreen( DriverScreen.StartRoute -> RouteStageScreen(
state = state, state = state,
title = if (routeStageRequirementIsVisible(state.loadingPhotoRequirement)) "Zdjęcia załadunku" else "Potwierdź załadunek", title = if (state.routeWorkflow != null) "Załadunek" else if (routeStageRequirementIsVisible(state.loadingPhotoRequirement)) "Zdjęcia załadunku" else "Potwierdź załadunek",
stage = "loading", stage = "loading",
weightLabel = "Tonaż załadunku", weightLabel = "Tonaż załadunku",
submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek", submitLabel = if (state.routeWorkflow != null) "Potwierdź załadunek" else if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek",
showWeightInput = false, showWeightInput = state.routeWorkflow?.let { routeWorkflowSteps(it, "loading").any { step -> step.type == RouteWorkflowStepType.Weight } } ?: false,
showNotesInput = !routeStageRequirementIsVisible(state.loadingWeightRequirement), showNotesInput = state.routeWorkflow != null || !routeStageRequirementIsVisible(state.loadingWeightRequirement),
showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement), showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement), showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes, onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading")?.join() }, onUpload = { uri, source, metadata, stepId, documentId -> viewModel.uploadPhoto(uri, source, metadata, "loading", stepId, documentId)?.join() },
onStepToggle = viewModel::toggleRouteStageStep,
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto, onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, onSubmit = if (state.routeWorkflow == null && routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute,
) )
DriverScreen.LoadingWeight -> RouteStageScreen( DriverScreen.LoadingWeight -> RouteStageScreen(
state = state, state = state,
@@ -511,7 +514,8 @@ fun DriverApp(
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes, onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { _, _, _ -> }, onUpload = { _, _, _, _, _ -> },
onStepToggle = viewModel::toggleRouteStageStep,
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto, onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
@@ -531,7 +535,8 @@ fun DriverApp(
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes, onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading")?.join() }, onUpload = { uri, source, metadata, stepId, documentId -> viewModel.uploadPhoto(uri, source, metadata, "unloading", stepId, documentId)?.join() },
onStepToggle = viewModel::toggleRouteStageStep,
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto, onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
@@ -1198,9 +1203,22 @@ private fun RoutesScreen(
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var cameraLocationMetadata by remember { mutableStateOf(PhotoUploadMetadata()) }
var cameraMetadataGeneration by remember { mutableStateOf(0L) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var showRouteDatePicker by remember { mutableStateOf(false) } var showRouteDatePicker by remember { mutableStateOf(false) }
var showTachographConfirmation by remember { mutableStateOf(false) } var showTachographConfirmation by remember { mutableStateOf(false) }
val prepareCameraMetadata = {
val generation = cameraMetadataGeneration + 1
cameraMetadataGeneration = generation
cameraLocationMetadata = PhotoUploadMetadata()
coroutineScope.launch {
val metadata = cameraPhotoMetadata(context).copy(takenAt = null)
if (cameraMetadataGeneration == generation) {
cameraLocationMetadata = metadata
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri val capturedUri = cameraUri
if ( if (
@@ -1211,10 +1229,13 @@ private fun RoutesScreen(
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
) )
) { ) {
coroutineScope.launch { onDispatchSheetUpload(
onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context)) capturedUri,
} "camera",
cameraLocationMetadata.copy(takenAt = Instant.now().toString()),
)
} }
cameraMetadataGeneration += 1
cameraUri = null cameraUri = null
} }
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
@@ -1224,10 +1245,14 @@ private fun RoutesScreen(
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) { if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
showPreciseLocationPermissionDialog = true showPreciseLocationPermissionDialog = true
cameraMetadataGeneration += 1
cameraUri = null cameraUri = null
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri) if (cameraGranted && pendingUri != null) {
prepareCameraMetadata()
cameraLauncher.launch(pendingUri)
}
} }
LaunchedEffect(state.routeDayLiveUpdateMessage) { LaunchedEffect(state.routeDayLiveUpdateMessage) {
@@ -1335,6 +1360,7 @@ private fun RoutesScreen(
requirePreciseLocation = state.requirePreciseLocationForPhotos, requirePreciseLocation = state.requirePreciseLocationForPhotos,
) )
if (missingPermissions.isEmpty()) { if (missingPermissions.isEmpty()) {
prepareCameraMetadata()
cameraLauncher.launch(newUri) cameraLauncher.launch(newUri)
} else { } else {
cameraPermissionLauncher.launch(missingPermissions) cameraPermissionLauncher.launch(missingPermissions)
@@ -4427,7 +4453,8 @@ private fun RouteStageScreen(
onBack: () -> Unit, onBack: () -> Unit,
onWeightChange: (String) -> Unit, onWeightChange: (String) -> Unit,
onNotesChange: (String) -> Unit, onNotesChange: (String) -> Unit,
onUpload: suspend (Uri, String, PhotoUploadMetadata) -> Unit, onUpload: suspend (Uri, String, PhotoUploadMetadata, String?, String?) -> Unit,
onStepToggle: (String, Boolean) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit,
@@ -4436,20 +4463,40 @@ private fun RouteStageScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by rememberSaveable(stage) { mutableStateOf<Uri?>(null) }
var pendingCameraMode by remember { mutableStateOf<RouteStageCameraMode?>(null) } var pendingCameraMode by rememberSaveable(stage) { mutableStateOf<RouteStageCameraMode?>(null) }
var showMultiplePhotoCamera by remember(stage) { mutableStateOf(false) } var showMultiplePhotoCamera by rememberSaveable(stage) { mutableStateOf(false) }
var multiplePhotoLocationMetadata by remember(stage) { mutableStateOf(PhotoUploadMetadata()) } var multiplePhotoLocationMetadata by rememberSaveable(stage) { mutableStateOf(PhotoUploadMetadata()) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by rememberSaveable(stage) { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) }
var cameraAttachmentTarget by rememberSaveable(stage) { mutableStateOf<RouteWorkflowAttachmentTarget?>(null) }
var pickerAttachmentTarget by rememberSaveable(stage) { mutableStateOf<RouteWorkflowAttachmentTarget?>(null) }
var singlePhotoLocationMetadata by rememberSaveable(stage) { mutableStateOf(PhotoUploadMetadata()) }
var singlePhotoMetadataGeneration by rememberSaveable(stage) { mutableStateOf(0L) }
val route = state.displaySelectedRoute 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 stagePreparations = state.preparingPhotoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
val usableStageUploads = routeStageUsableUploads(stageUploads) val usableStageUploads = routeStageUsableUploads(stageUploads)
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, usableStageUploads.size) val cargoStagePhotos = stagePhotos.filter { it.workflowDocumentId.isNullOrBlank() }
val cargoStageUploads = usableStageUploads.filter { it.workflowDocumentId.isNullOrBlank() }
val cargoStagePreparations = stagePreparations.filter { it.workflowDocumentId.isNullOrBlank() }
val attachmentCount = visiblePhotoAttachmentCount(
cargoStagePhotos.size,
cargoStageUploads.size + cargoStagePreparations.size,
)
val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement
val submitBlocker = when { val submitBlocker = if (state.routeWorkflow != null) {
routeWorkflowSubmitBlocker(
workflow = state.routeWorkflow,
stage = stage,
completedStepIds = state.routeStageCompletedStepIds,
weightText = state.routeStageWeightText,
serverPhotos = stagePhotos,
localUploads = usableStageUploads,
)
} else when {
!showWeightInput -> loadingPhotosSubmitBlocker( !showWeightInput -> loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = usableStageUploads.size, localUploadCount = usableStageUploads.size,
@@ -4476,9 +4523,21 @@ private fun RouteStageScreen(
submitBlocker submitBlocker
} }
val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) } val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) }
val prepareSinglePhotoMetadata = {
val generation = singlePhotoMetadataGeneration + 1
singlePhotoMetadataGeneration = generation
singlePhotoLocationMetadata = PhotoUploadMetadata()
coroutineScope.launch {
val metadata = cameraPhotoMetadata(context).copy(takenAt = null)
if (singlePhotoMetadataGeneration == generation) {
singlePhotoLocationMetadata = metadata
}
}
}
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri val capturedUri = cameraUri
val attachmentTarget = cameraAttachmentTarget
if ( if (
ok && ok &&
capturedUri != null && capturedUri != null &&
@@ -4488,10 +4547,18 @@ private fun RouteStageScreen(
) )
) { ) {
coroutineScope.launch { coroutineScope.launch {
onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) onUpload(
capturedUri,
"camera",
singlePhotoLocationMetadata.copy(takenAt = Instant.now().toString()),
attachmentTarget?.stepId,
attachmentTarget?.documentId,
)
} }
} }
singlePhotoMetadataGeneration += 1
cameraUri = null cameraUri = null
cameraAttachmentTarget = null
} }
val openMultiplePhotoCamera = { val openMultiplePhotoCamera = {
multiplePhotoLocationMetadata = PhotoUploadMetadata() multiplePhotoLocationMetadata = PhotoUploadMetadata()
@@ -4508,6 +4575,7 @@ private fun RouteStageScreen(
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) { if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
showPreciseLocationPermissionDialog = true showPreciseLocationPermissionDialog = true
singlePhotoMetadataGeneration += 1
cameraUri = null cameraUri = null
pendingCameraMode = null pendingCameraMode = null
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
@@ -4515,7 +4583,10 @@ private fun RouteStageScreen(
if (cameraGranted) { if (cameraGranted) {
when (requestedMode) { when (requestedMode) {
RouteStageCameraMode.Single -> { RouteStageCameraMode.Single -> {
if (pendingUri != null) cameraLauncher.launch(pendingUri) if (pendingUri != null) {
prepareSinglePhotoMetadata()
cameraLauncher.launch(pendingUri)
}
} }
RouteStageCameraMode.Multiple -> { RouteStageCameraMode.Multiple -> {
openMultiplePhotoCamera() openMultiplePhotoCamera()
@@ -4528,11 +4599,34 @@ private fun RouteStageScreen(
pendingCameraMode = null pendingCameraMode = null
} }
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
val attachmentTarget = pickerAttachmentTarget
if (uri != null) { if (uri != null) {
coroutineScope.launch { coroutineScope.launch {
onUpload(uri, "gallery", PhotoUploadMetadata()) onUpload(
uri,
"gallery",
PhotoUploadMetadata(),
attachmentTarget?.stepId,
attachmentTarget?.documentId,
)
} }
} }
pickerAttachmentTarget = null
}
val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
val attachmentTarget = pickerAttachmentTarget
if (uri != null) {
coroutineScope.launch {
onUpload(
uri,
"file",
PhotoUploadMetadata(),
attachmentTarget?.stepId,
attachmentTarget?.documentId,
)
}
}
pickerAttachmentTarget = null
} }
if (showMultiplePhotoCamera) { if (showMultiplePhotoCamera) {
@@ -4543,6 +4637,8 @@ private fun RouteStageScreen(
uri, uri,
"camera", "camera",
multiplePhotoLocationMetadata.copy(takenAt = Instant.now().toString()), multiplePhotoLocationMetadata.copy(takenAt = Instant.now().toString()),
null,
null,
) )
}, },
onClose = { onClose = {
@@ -4573,7 +4669,44 @@ private fun RouteStageScreen(
if (route != null) { if (route != null) {
item { RouteStageSummaryCard(route) } item { RouteStageSummaryCard(route) }
} }
if (state.routeWorkflow != null) {
item { item {
RouteWorkflowStepsCard(
steps = routeWorkflowSteps(state.routeWorkflow, stage),
completedStepIds = state.routeStageCompletedStepIds,
weightText = state.routeStageWeightText,
serverPhotos = stagePhotos,
localUploads = usableStageUploads,
preparingUploads = stagePreparations,
onStepToggle = onStepToggle,
onTakeDocumentPhoto = { stepId, documentId ->
cameraAttachmentTarget = RouteWorkflowAttachmentTarget(stepId, documentId)
val newUri = createCameraUri(context)
cameraUri = newUri
pendingCameraMode = RouteStageCameraMode.Single
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
pendingCameraMode = null
prepareSinglePhotoMetadata()
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(missingPermissions)
}
},
onPickDocumentPhoto = { stepId, documentId ->
pickerAttachmentTarget = RouteWorkflowAttachmentTarget(stepId, documentId)
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
onPickDocumentFile = { stepId, documentId ->
pickerAttachmentTarget = RouteWorkflowAttachmentTarget(stepId, documentId)
filePickerLauncher.launch(arrayOf("application/pdf", "image/jpeg", "image/png"))
},
)
}
} else item {
RouteStageChecklist( RouteStageChecklist(
hasValidWeight = if (showWeightInput) { hasValidWeight = if (showWeightInput) {
state.routeStageWeightText.trim().isNotBlank() && routeStageSubmitBlocker( state.routeStageWeightText.trim().isNotBlank() && routeStageSubmitBlocker(
@@ -4633,6 +4766,7 @@ private fun RouteStageScreen(
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
RouteStageCameraActions( RouteStageCameraActions(
onSinglePhoto = { onSinglePhoto = {
cameraAttachmentTarget = null
val newUri = createCameraUri(context) val newUri = createCameraUri(context)
cameraUri = newUri cameraUri = newUri
pendingCameraMode = RouteStageCameraMode.Single pendingCameraMode = RouteStageCameraMode.Single
@@ -4642,12 +4776,14 @@ private fun RouteStageScreen(
) )
if (missingPermissions.isEmpty()) { if (missingPermissions.isEmpty()) {
pendingCameraMode = null pendingCameraMode = null
prepareSinglePhotoMetadata()
cameraLauncher.launch(newUri) cameraLauncher.launch(newUri)
} else { } else {
cameraPermissionLauncher.launch(missingPermissions) cameraPermissionLauncher.launch(missingPermissions)
} }
}, },
onMultiplePhotos = { onMultiplePhotos = {
cameraAttachmentTarget = null
cameraUri = null cameraUri = null
pendingCameraMode = RouteStageCameraMode.Multiple pendingCameraMode = RouteStageCameraMode.Multiple
val missingPermissions = cameraCapturePermissions( val missingPermissions = cameraCapturePermissions(
@@ -4664,6 +4800,7 @@ private fun RouteStageScreen(
) )
if (state.allowGalleryUploads) { if (state.allowGalleryUploads) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) {
pickerAttachmentTarget = null
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
} }
} }
@@ -4673,8 +4810,9 @@ private fun RouteStageScreen(
if (showPhotoGrid) { if (showPhotoGrid) {
item { item {
PhotoGrid( PhotoGrid(
photos = stagePhotos, photos = cargoStagePhotos,
uploads = stageUploads, uploads = stageUploads.filter { it.workflowDocumentId.isNullOrBlank() },
preparations = cargoStagePreparations,
deletingPhotoIds = state.deletingPhotoIds, deletingPhotoIds = state.deletingPhotoIds,
imageAuthHeader = state.imageAuthHeader, imageAuthHeader = state.imageAuthHeader,
onPhoto = onPhoto, onPhoto = onPhoto,
@@ -4718,6 +4856,168 @@ private fun RouteStageScreen(
} }
} }
private data class RouteWorkflowAttachmentTarget(
val stepId: String,
val documentId: String,
) : java.io.Serializable
@Composable
private fun RouteWorkflowStepsCard(
steps: List<DriverRouteWorkflowStepDto>,
completedStepIds: Set<String>,
weightText: String,
serverPhotos: List<RoutePhotoDto>,
localUploads: List<PhotoUploadEntity>,
preparingUploads: List<PreparingPhotoUpload>,
onStepToggle: (String, Boolean) -> Unit,
onTakeDocumentPhoto: (String, String) -> Unit,
onPickDocumentPhoto: (String, String) -> Unit,
onPickDocumentFile: (String, String) -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text(
"Kroki do wykonania",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
if (steps.isEmpty()) {
Text("Brak dodatkowych czynności. Możesz potwierdzić etap.", color = TppTheme.colors.muted)
}
steps.forEachIndexed { index, step ->
val required = routeStageRequirementIsRequired(step.requirement)
val done = when (step.type) {
RouteWorkflowStepType.Confirmation -> step.id in completedStepIds
RouteWorkflowStepType.Weight -> parseRouteWeightTons(weightText)?.let { it > 0.0 } == true
RouteWorkflowStepType.Photos ->
serverPhotos.any { it.workflowDocumentId.isNullOrBlank() } ||
localUploads.any {
it.workflowDocumentId.isNullOrBlank() &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue &&
it.status != PhotoUploadStatus.Cancelled.storageValue
}
else -> false
}
Column(
Modifier
.fillMaxWidth()
.background(TppTheme.colors.panel, RoundedCornerShape(8.dp))
.border(1.dp, TppTheme.colors.outline.copy(alpha = 0.7f), RoundedCornerShape(8.dp))
.padding(13.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier
.size(30.dp)
.background(if (done) TppTheme.colors.successContainer else TppTheme.colors.card, RoundedCornerShape(15.dp)),
contentAlignment = Alignment.Center,
) {
Text(
if (done) "" else "${index + 1}",
color = if (done) TppTheme.colors.forest else TppTheme.colors.muted,
fontWeight = FontWeight.Bold,
)
}
Column(Modifier.weight(1f)) {
Text(step.title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold)
Text(
if (required) "WYMAGANY" else "OPCJONALNY",
color = if (required) TppTheme.colors.error else TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.labelSmall,
)
}
if (step.type == RouteWorkflowStepType.Confirmation) {
Checkbox(
checked = step.id in completedStepIds,
onCheckedChange = { onStepToggle(step.id, it) },
)
}
}
if (step.description.isNotBlank()) {
Text(step.description, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyMedium)
}
step.documents.forEach { document ->
val documentServerPhotos = serverPhotos.filter {
it.workflowStepId == step.id && it.workflowDocumentId == document.id
}
val documentUploads = localUploads.filter {
it.workflowStepId == step.id && it.workflowDocumentId == document.id &&
it.status != PhotoUploadStatus.Cancelled.storageValue &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue
}
val documentPreparations = preparingUploads.count {
it.workflowStepId == step.id && it.workflowDocumentId == document.id
}
val count = documentServerPhotos.size + documentUploads.size + documentPreparations
Column(
Modifier
.fillMaxWidth()
.background(TppTheme.colors.card, RoundedCornerShape(6.dp))
.padding(11.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Description, contentDescription = null, tint = TppTheme.colors.forest)
Column(Modifier.weight(1f)) {
Text(document.label, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold)
Text(
if (count > 0) "$count ${photoCountLabel(count)}" else if (routeStageRequirementIsRequired(document.requirement)) "wymagany dokument" else "opcjonalny dokument",
color = if (count > 0) TppTheme.colors.forest else TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.labelSmall,
)
}
}
if (document.description.isNotBlank()) {
Text(document.description, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = { onTakeDocumentPhoto(step.id, document.id) },
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp),
) {
Icon(Icons.Outlined.CameraAlt, contentDescription = null, modifier = Modifier.size(17.dp))
Spacer(Modifier.width(4.dp))
Text("Aparat", style = MaterialTheme.typography.labelMedium)
}
OutlinedButton(
onClick = { onPickDocumentPhoto(step.id, document.id) },
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp),
) {
Icon(Icons.Outlined.AddPhotoAlternate, contentDescription = null, modifier = Modifier.size(17.dp))
Spacer(Modifier.width(4.dp))
Text("Galeria", style = MaterialTheme.typography.labelMedium)
}
OutlinedButton(
onClick = { onPickDocumentFile(step.id, document.id) },
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 8.dp),
) {
Icon(Icons.Outlined.Description, contentDescription = null, modifier = Modifier.size(17.dp))
Spacer(Modifier.width(4.dp))
Text("Plik", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
}
}
}
}
@Composable @Composable
private fun RouteStageSummaryCard(route: DriverRouteDto) { private fun RouteStageSummaryCard(route: DriverRouteDto) {
Card( Card(
@@ -5707,6 +6007,7 @@ private fun CargoActionButton(label: String, icon: ImageVector, color: Color, on
private fun PhotoGrid( private fun PhotoGrid(
photos: List<RoutePhotoDto>, photos: List<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>, uploads: List<PhotoUploadEntity>,
preparations: List<PreparingPhotoUpload> = emptyList(),
deletingPhotoIds: Set<String>, deletingPhotoIds: Set<String>,
imageAuthHeader: String?, imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
@@ -5716,9 +6017,13 @@ private fun PhotoGrid(
readOnly: Boolean = false, 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 = (
preparations.map { PhotoGridItem.Preparing(it) } +
uploads.map { PhotoGridItem.Upload(it) } +
photos.map { PhotoGridItem.Server(it) }
)
.sortedByDescending { it.sortEpochMillis } .sortedByDescending { it.sortEpochMillis }
val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size) val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size + preparations.size)
Card( Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -5765,6 +6070,10 @@ private fun PhotoGrid(
readOnly = readOnly, readOnly = readOnly,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
is PhotoGridItem.Preparing -> PreparingPhotoTile(
preparation = item.preparation,
modifier = Modifier.weight(1f),
)
} }
} }
if (rowItems.size == 1) { if (rowItems.size == 1) {
@@ -5811,6 +6120,9 @@ private sealed class PhotoGridItem(open val sortEpochMillis: Long) {
) )
data class Upload(val upload: PhotoUploadEntity) : PhotoGridItem(upload.createdAtEpochMillis) data class Upload(val upload: PhotoUploadEntity) : PhotoGridItem(upload.createdAtEpochMillis)
data class Preparing(val preparation: PreparingPhotoUpload) :
PhotoGridItem(preparation.createdAtEpochMillis)
} }
private sealed class PhotoDeleteTarget { private sealed class PhotoDeleteTarget {
@@ -6044,6 +6356,40 @@ private fun PendingPhotoTile(
} }
} }
@Composable
private fun PreparingPhotoTile(
preparation: PreparingPhotoUpload,
modifier: Modifier = Modifier,
) {
Box(modifier.aspectRatio(1f).background(TppTheme.colors.panel, RoundedCornerShape(4.dp))) {
AsyncImage(
model = preparation.uri,
contentDescription = "Przygotowywane zdjęcie",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.24f)))
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center).size(30.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f),
strokeWidth = 2.5.dp,
)
Text(
"Przygotowuję zdjęcie",
color = TppTheme.colors.ink,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(Color.White.copy(alpha = 0.94f))
.padding(10.dp),
)
}
}
@Composable @Composable
private fun PhotoTileLoadingOverlay() { private fun PhotoTileLoadingOverlay() {
Box( Box(
@@ -12,6 +12,8 @@ import java.time.temporal.ChronoUnit
import java.util.Locale import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.TachographReminderDto
@@ -278,6 +280,75 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
object RouteWorkflowStepType {
const val Confirmation = "confirmation"
const val Weight = "weight"
const val Photos = "photos"
}
fun routeWorkflowSteps(workflow: DriverRouteWorkflowDto?, stage: String): List<DriverRouteWorkflowStepDto> =
when (stage) {
"loading" -> workflow?.loading?.steps
"unloading" -> workflow?.unloading?.steps
else -> null
}.orEmpty().filter { routeStageRequirementIsVisible(it.requirement) }
fun routeWorkflowSubmitBlocker(
workflow: DriverRouteWorkflowDto?,
stage: String,
completedStepIds: Set<String>,
weightText: String,
serverPhotos: List<RoutePhotoDto>,
localUploads: List<PhotoUploadEntity>,
): String? {
val steps = routeWorkflowSteps(workflow, stage)
if (steps.isEmpty()) return null
val normalizedWeight = parseRouteWeightTons(weightText)
for (step in steps) {
val required = routeStageRequirementIsRequired(step.requirement)
when (step.type) {
RouteWorkflowStepType.Confirmation -> if (required && step.id !in completedStepIds) {
return "Potwierdź krok: ${step.title}."
}
RouteWorkflowStepType.Weight -> when {
required && weightText.isBlank() -> return "Uzupełnij krok: ${step.title}."
weightText.isNotBlank() && normalizedWeight == null -> return "Podaj poprawny tonaż."
weightText.isNotBlank() && normalizedWeight != null && normalizedWeight <= 0.0 -> return "Tonaż musi być większy od zera."
weightText.isNotBlank() && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> return "Tonaż nie może przekraczać 999,999 t."
}
RouteWorkflowStepType.Photos -> if (
required &&
serverPhotos.none { it.workflowDocumentId.isNullOrBlank() } &&
localUploads.none {
it.workflowDocumentId.isNullOrBlank() &&
it.status != PhotoUploadStatus.Cancelled.storageValue &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue
}
) {
return "Dodaj zdjęcie dla kroku: ${step.title}."
}
}
for (document in step.documents.filter { routeStageRequirementIsRequired(it.requirement) }) {
val serverPresent = serverPhotos.any {
it.workflowStepId == step.id && it.workflowDocumentId == document.id
}
val localPresent = localUploads.any {
it.workflowStepId == step.id &&
it.workflowDocumentId == document.id &&
it.status != PhotoUploadStatus.Cancelled.storageValue &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue
}
if (!serverPresent && !localPresent) {
return "Dodaj dokument „${document.label}” w kroku „${step.title}”."
}
}
}
return null
}
fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength) fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength)
fun parseRouteWeightTons(weightText: String): Double? { fun parseRouteWeightTons(weightText: String): Double? {
@@ -39,6 +39,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
@@ -72,6 +73,16 @@ enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Diagnostics
enum class ServerConnectionState { Unknown, Reachable, Degraded } enum class ServerConnectionState { Unknown, Reachable, Degraded }
data class PreparingPhotoUpload(
val id: String,
val routeId: String,
val uri: Uri,
val stage: String,
val workflowStepId: String?,
val workflowDocumentId: String?,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
)
data class DriverUiState( data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true, val loading: Boolean = true,
@@ -98,6 +109,7 @@ data class DriverUiState(
val loadingWeightRequirement: String = RouteStageRequirement.Required, val loadingWeightRequirement: String = RouteStageRequirement.Required,
val unloadingPhotoRequirement: String = RouteStageRequirement.Required, val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
val unloadingWeightRequirement: String = RouteStageRequirement.Required, val unloadingWeightRequirement: String = RouteStageRequirement.Required,
val routeWorkflow: DriverRouteWorkflowDto? = null,
val notifyNewRoutes: Boolean = false, val notifyNewRoutes: Boolean = false,
val routeProgressNotificationEnabled: Boolean = true, val routeProgressNotificationEnabled: Boolean = true,
val notificationPermissionDenied: Boolean = false, val notificationPermissionDenied: Boolean = false,
@@ -105,9 +117,11 @@ data class DriverUiState(
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "", val routeStageWeightText: String = "",
val routeStageNotesText: String = "", val routeStageNotesText: String = "",
val routeStageCompletedStepIds: Set<String> = emptySet(),
val routeActions: List<RouteActionEntity> = emptyList(), val routeActions: List<RouteActionEntity> = emptyList(),
val visibleRouteActions: List<RouteActionEntity> = emptyList(), val visibleRouteActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val preparingPhotoUploads: List<PreparingPhotoUpload> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null, val tachographReminder: TachographReminderDto? = null,
@@ -613,6 +627,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
unloadingWeightRequirement = normalizeRouteStageRequirement(settings?.unloadingWeightRequirement), unloadingWeightRequirement = normalizeRouteStageRequirement(settings?.unloadingWeightRequirement),
routeWorkflow = settings?.routeWorkflow,
leaveRequestsConfig = settings?.leaveRequests?.let { config -> leaveRequestsConfig = settings?.leaveRequests?.let { config ->
LeaveRequestsConfig(enabled = config.enabled, types = config.types) LeaveRequestsConfig(enabled = config.enabled, types = config.types)
}, },
@@ -1483,6 +1498,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
source: String, source: String,
metadata: PhotoUploadMetadata = PhotoUploadMetadata(), metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
stage: String = "other", stage: String = "other",
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): Job? { ): Job? {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return null val route = snapshot.selectedRoute ?: return null
@@ -1490,12 +1507,46 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") } _state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") }
return null return null
} }
val preparation = PreparingPhotoUpload(
id = UUID.randomUUID().toString(),
routeId = route.id,
uri = uri,
stage = stage,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
)
_state.update {
it.copy(
preparingPhotoUploads = it.preparingPhotoUploads + preparation,
error = null,
)
}
return viewModelScope.launch { return viewModelScope.launch {
_state.update { it.copy(error = null) } try {
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) } runCatching {
photoUploadOutbox.enqueue(
routeId = route.id,
uri = uri,
source = source,
metadata = metadata,
stage = stage,
workflowVersion = snapshot.routeWorkflow?.version,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
)
}
.onFailure { throwable -> .onFailure { throwable ->
_state.update { it.withApiError(throwable) } _state.update { it.withApiError(throwable) }
} }
} finally {
_state.update {
it.copy(
preparingPhotoUploads = it.preparingPhotoUploads.filterNot { item ->
item.id == preparation.id
},
)
}
}
} }
} }
@@ -1510,7 +1561,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return return
} }
val targetScreen = if ( val targetScreen = if (snapshot.routeWorkflow != null) {
DriverScreen.StartRoute
} else if (
!routeStageRequirementIsVisible(snapshot.loadingPhotoRequirement) && !routeStageRequirementIsVisible(snapshot.loadingPhotoRequirement) &&
routeStageRequirementIsVisible(snapshot.loadingWeightRequirement) routeStageRequirementIsVisible(snapshot.loadingWeightRequirement)
) DriverScreen.LoadingWeight else DriverScreen.StartRoute ) DriverScreen.LoadingWeight else DriverScreen.StartRoute
@@ -1519,6 +1572,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
screen = targetScreen, screen = targetScreen,
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = route.loadingNotes.orEmpty(), routeStageNotesText = route.loadingNotes.orEmpty(),
routeStageCompletedStepIds = emptySet(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -1564,6 +1618,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
screen = DriverScreen.FinishRoute, screen = DriverScreen.FinishRoute,
routeStageWeightText = route.unloadingWeight?.toString().orEmpty(), routeStageWeightText = route.unloadingWeight?.toString().orEmpty(),
routeStageNotesText = route.unloadingNotes.orEmpty(), routeStageNotesText = route.unloadingNotes.orEmpty(),
routeStageCompletedStepIds = emptySet(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -1579,6 +1634,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) } _state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) }
} }
fun toggleRouteStageStep(stepId: String, completed: Boolean) {
_state.update {
it.copy(
routeStageCompletedStepIds = if (completed) {
it.routeStageCompletedStepIds + stepId
} else {
it.routeStageCompletedStepIds - stepId
},
)
}
}
fun correctFailedRouteAction() { fun correctFailedRouteAction() {
val snapshot = _state.value val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions) val action = (snapshot.visibleRouteActions + snapshot.routeActions)
@@ -1677,7 +1744,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val stageUploads = routeStageUsableUploads( val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }, snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
) )
val submitBlocker = if (stage == "loading") { val submitBlocker = if (snapshot.routeWorkflow != null) {
routeWorkflowSubmitBlocker(
workflow = snapshot.routeWorkflow,
stage = stage,
completedStepIds = snapshot.routeStageCompletedStepIds,
weightText = snapshot.routeStageWeightText,
serverPhotos = stagePhotos,
localUploads = stageUploads,
)
} else if (stage == "loading") {
loadingWeightSubmitBlocker( loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText, weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
@@ -1700,7 +1776,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
return return
} }
val photoClientRequestIds = if (routeStageRequirementIsVisible(photoRequirement)) { val photoClientRequestIds = if (snapshot.routeWorkflow != null || routeStageRequirementIsVisible(photoRequirement)) {
(stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId }) (stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId })
.map { it.trim() } .map { it.trim() }
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
@@ -1719,7 +1795,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { runCatching {
val updatedRoute = when (stage) { val updatedRoute = when (stage) {
"loading" -> { "loading" -> {
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds, notes) routeActionOutbox.enqueueStart(
route.id,
weight,
photoClientRequestIds,
notes,
snapshot.routeWorkflow?.version,
snapshot.routeStageCompletedStepIds.toList(),
)
route.copy( route.copy(
status = "W TRAKCIE", status = "W TRAKCIE",
driverStatus = "W TRAKCIE", driverStatus = "W TRAKCIE",
@@ -1730,7 +1813,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
"unloading" -> { "unloading" -> {
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds, notes) routeActionOutbox.enqueueFinish(
route.id,
weight,
photoClientRequestIds,
notes,
snapshot.routeWorkflow?.version,
snapshot.routeStageCompletedStepIds.toList(),
)
route.copy( route.copy(
status = "ZAKOŃCZONA", status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA", driverStatus = "ZAKOŃCZONA",
@@ -1751,6 +1841,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item },
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = "", routeStageNotesText = "",
routeStageCompletedStepIds = emptySet(),
feedback = if (stage == "loading") { feedback = if (stage == "loading") {
if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu."
} else { } else {
@@ -1960,7 +2051,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.Detail -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
routeActionsJob?.cancel() routeActionsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, routeActions = emptyList(), photoUploads = emptyList(), feedback = null) it.copy(
screen = DriverScreen.Routes,
selectedRoute = null,
routeActions = emptyList(),
photoUploads = emptyList(),
preparingPhotoUploads = emptyList(),
feedback = null,
)
} }
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes) DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Diagnostics -> it.copy(screen = DriverScreen.Profile) DriverScreen.Diagnostics -> it.copy(screen = DriverScreen.Profile)
@@ -43,6 +43,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -74,7 +75,7 @@ internal fun MultiplePhotoCameraScreen(
imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
} }
} }
var capturedCount by remember { mutableIntStateOf(0) } var capturedCount by rememberSaveable(stage) { mutableIntStateOf(0) }
var pendingUploads by remember { mutableIntStateOf(0) } var pendingUploads by remember { mutableIntStateOf(0) }
var isCapturing by remember { mutableStateOf(false) } var isCapturing by remember { mutableStateOf(false) }
var cameraUnavailable by remember { mutableStateOf(false) } var cameraUnavailable by remember { mutableStateOf(false) }
@@ -6,6 +6,10 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteStageWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDocumentDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.TachographReminderDto
@@ -18,6 +22,52 @@ 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")
@Test
fun customWorkflowBlocksUntilConfirmationAndRequiredDocumentArePresent() {
val workflow = DriverRouteWorkflowDto(
version = "workflow-v1",
customized = true,
loading = DriverRouteStageWorkflowDto(
steps = listOf(
DriverRouteWorkflowStepDto(
id = "secure-load",
type = RouteWorkflowStepType.Confirmation,
title = "Sprawdź zabezpieczenie ładunku",
requirement = RouteStageRequirement.Required,
documents = listOf(
DriverRouteWorkflowDocumentDto(
id = "cmr",
label = "List przewozowy CMR",
requirement = RouteStageRequirement.Required,
),
),
),
),
),
unloading = DriverRouteStageWorkflowDto(),
)
assertEquals(
"Potwierdź krok: Sprawdź zabezpieczenie ładunku.",
routeWorkflowSubmitBlocker(workflow, "loading", emptySet(), "", emptyList(), emptyList()),
)
assertEquals(
"Dodaj dokument „List przewozowy CMR” w kroku „Sprawdź zabezpieczenie ładunku”.",
routeWorkflowSubmitBlocker(workflow, "loading", setOf("secure-load"), "", emptyList(), emptyList()),
)
assertEquals(
null,
routeWorkflowSubmitBlocker(
workflow,
"loading",
setOf("secure-load"),
"",
listOf(photo("cmr-photo", "loading").copy(workflowStepId = "secure-load", workflowDocumentId = "cmr")),
emptyList(),
),
)
}
@Test @Test
fun disabledRouteLocationNeverStartsTracking() { fun disabledRouteLocationNeverStartsTracking() {
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true)) assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))