Compare commits

...
15 changed files with 824 additions and 81 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 = 129 versionCode = 132
versionName = "1.0.76" versionName = "1.0.79"
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"],
@@ -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?,
@@ -13,7 +13,9 @@ import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -29,12 +31,22 @@ class PhotoUploadOutbox(
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> = fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads() dao.observeQueuedUploads()
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata, stage: String = "other"): PhotoUploadEntity { suspend fun enqueue(
routeId: String,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
stage: String = "other",
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): 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() }
@@ -46,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,
@@ -58,7 +73,7 @@ class PhotoUploadOutbox(
dao.upsert(upload) dao.upsert(upload)
enqueueWorker(clientRequestId) enqueueWorker(clientRequestId)
return upload upload
} }
suspend fun retry(clientRequestId: String) { suspend fun retry(clientRequestId: String) {
@@ -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"
@@ -62,7 +62,6 @@ import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
@@ -167,6 +166,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
@@ -470,6 +470,9 @@ fun DriverApp(
locationPermissionGranted = locationPermissionGranted, locationPermissionGranted = locationPermissionGranted,
onBack = viewModel::back, onBack = viewModel::back,
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onRefresh = viewModel::refreshSelectedRoute, onRefresh = viewModel::refreshSelectedRoute,
onStartRoute = openStartRoute, onStartRoute = openStartRoute,
onFinishRoute = viewModel::openFinishRoute, onFinishRoute = viewModel::openFinishRoute,
@@ -478,22 +481,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") }, 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,
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,
@@ -508,8 +513,10 @@ 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,
onDeleteUpload = viewModel::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitStartRoute, onSubmit = viewModel::submitStartRoute,
@@ -527,8 +534,10 @@ 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") }, 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,
onDeleteUpload = viewModel::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitFinishRoute, onSubmit = viewModel::submitFinishRoute,
@@ -1193,9 +1202,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 (
@@ -1206,10 +1228,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 ->
@@ -1219,10 +1244,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) {
@@ -1330,6 +1359,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)
@@ -4422,8 +4452,10 @@ private fun RouteStageScreen(
onBack: () -> Unit, onBack: () -> Unit,
onWeightChange: (String) -> Unit, onWeightChange: (String) -> Unit,
onNotesChange: (String) -> Unit, onNotesChange: (String) -> Unit,
onUpload: (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,
onDeleteUpload: (PhotoUploadEntity) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit,
onSubmit: () -> Unit, onSubmit: () -> Unit,
@@ -4433,16 +4465,37 @@ private fun RouteStageScreen(
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var pendingCameraMode by remember { mutableStateOf<RouteStageCameraMode?>(null) } var pendingCameraMode by remember { mutableStateOf<RouteStageCameraMode?>(null) }
var showMultiplePhotoCamera by remember(stage) { mutableStateOf(false) } var showMultiplePhotoCamera by remember(stage) { mutableStateOf(false) }
var multiplePhotoLocationMetadata by remember(stage) { mutableStateOf(PhotoUploadMetadata()) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) }
var cameraAttachmentTarget by remember(stage) { mutableStateOf<RouteWorkflowAttachmentTarget?>(null) }
var pickerAttachmentTarget by remember(stage) { mutableStateOf<RouteWorkflowAttachmentTarget?>(null) }
var singlePhotoLocationMetadata by remember(stage) { mutableStateOf(PhotoUploadMetadata()) }
var singlePhotoMetadataGeneration by remember(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,
@@ -4469,9 +4522,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 &&
@@ -4481,10 +4546,25 @@ 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 = {
multiplePhotoLocationMetadata = PhotoUploadMetadata()
showMultiplePhotoCamera = true
coroutineScope.launch {
multiplePhotoLocationMetadata = cameraPhotoMetadata(context).copy(takenAt = null)
}
} }
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val requestedMode = pendingCameraMode val requestedMode = pendingCameraMode
@@ -4494,6 +4574,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
@@ -4501,10 +4582,13 @@ 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 -> {
showMultiplePhotoCamera = true openMultiplePhotoCamera()
} }
null -> Unit null -> Unit
} }
@@ -4514,16 +4598,47 @@ private fun RouteStageScreen(
pendingCameraMode = null pendingCameraMode = null
} }
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata()) val attachmentTarget = pickerAttachmentTarget
if (uri != null) {
coroutineScope.launch {
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) {
MultiplePhotoCameraScreen( MultiplePhotoCameraScreen(
stage = stage, stage = stage,
onPhotoCaptured = { uri -> onPhotoCaptured = { uri ->
coroutineScope.launch { onUpload(
onUpload(uri, "camera", cameraPhotoMetadata(context)) uri,
} "camera",
multiplePhotoLocationMetadata.copy(takenAt = Instant.now().toString()),
null,
null,
)
}, },
onClose = { onClose = {
showMultiplePhotoCamera = false showMultiplePhotoCamera = false
@@ -4553,7 +4668,44 @@ private fun RouteStageScreen(
if (route != null) { if (route != null) {
item { RouteStageSummaryCard(route) } item { RouteStageSummaryCard(route) }
} }
item { if (state.routeWorkflow != null) {
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(
@@ -4613,6 +4765,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
@@ -4622,12 +4775,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(
@@ -4636,7 +4791,7 @@ private fun RouteStageScreen(
) )
if (missingPermissions.isEmpty()) { if (missingPermissions.isEmpty()) {
pendingCameraMode = null pendingCameraMode = null
showMultiplePhotoCamera = true openMultiplePhotoCamera()
} else { } else {
cameraPermissionLauncher.launch(missingPermissions) cameraPermissionLauncher.launch(missingPermissions)
} }
@@ -4644,6 +4799,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))
} }
} }
@@ -4653,12 +4809,13 @@ 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,
onDeletePhoto = {}, onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload, onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload, onRetryUpload = onRetryUpload,
) )
@@ -4698,6 +4855,168 @@ private fun RouteStageScreen(
} }
} }
private data class RouteWorkflowAttachmentTarget(
val stepId: String,
val documentId: String,
)
@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(
@@ -4865,6 +5184,9 @@ private fun DetailScreen(
locationPermissionGranted: Boolean, locationPermissionGranted: Boolean,
onBack: () -> Unit, onBack: () -> 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,
@@ -4923,6 +5245,9 @@ private fun DetailScreen(
loadingWeightPending = projection.loadingWeightPending, loadingWeightPending = projection.loadingWeightPending,
unloadingWeightPending = projection.unloadingWeightPending, unloadingWeightPending = projection.unloadingWeightPending,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
) )
} }
item { ErrorText(state.error) } item { ErrorText(state.error) }
@@ -5391,6 +5716,9 @@ private fun CargoDocumentationSection(
loadingWeightPending: Boolean, loadingWeightPending: Boolean,
unloadingWeightPending: Boolean, unloadingWeightPending: Boolean,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> 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")
@@ -5415,6 +5743,9 @@ private fun CargoDocumentationSection(
imageAuthHeader = imageAuthHeader, imageAuthHeader = imageAuthHeader,
weightPending = loadingWeightPending, weightPending = loadingWeightPending,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
) )
RouteStageDocumentationBlock( RouteStageDocumentationBlock(
title = "Rozładunek", title = "Rozładunek",
@@ -5426,6 +5757,9 @@ private fun CargoDocumentationSection(
imageAuthHeader = imageAuthHeader, imageAuthHeader = imageAuthHeader,
weightPending = unloadingWeightPending, weightPending = unloadingWeightPending,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
) )
if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) { if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) {
RouteStageDocumentationBlock( RouteStageDocumentationBlock(
@@ -5438,6 +5772,9 @@ private fun CargoDocumentationSection(
imageAuthHeader = imageAuthHeader, imageAuthHeader = imageAuthHeader,
weightPending = false, weightPending = false,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
) )
} }
} }
@@ -5454,6 +5791,9 @@ private fun RouteStageDocumentationBlock(
imageAuthHeader: String?, imageAuthHeader: String?,
weightPending: Boolean = false, 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) val count = visiblePhotoAttachmentCount(photos.size, uploads.size)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -5536,10 +5876,9 @@ private fun RouteStageDocumentationBlock(
deletingPhotoIds = deletingPhotoIds, deletingPhotoIds = deletingPhotoIds,
imageAuthHeader = imageAuthHeader, imageAuthHeader = imageAuthHeader,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = {}, onDeletePhoto = onDeletePhoto,
onDeleteUpload = {}, onDeleteUpload = onDeleteUpload,
onRetryUpload = {}, onRetryUpload = onRetryUpload,
readOnly = true,
) )
} }
} }
@@ -5569,8 +5908,8 @@ private fun RouteStageCameraActions(
) )
RouteStageCameraActionButton( RouteStageCameraActionButton(
label = "Wiele zdjęć", label = "Wiele zdjęć",
icon = Icons.Outlined.Collections, icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.navy, color = TppTheme.colors.forest,
compact = true, compact = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onClick = onMultiplePhotos, onClick = onMultiplePhotos,
@@ -5588,8 +5927,8 @@ private fun RouteStageCameraActions(
) )
RouteStageCameraActionButton( RouteStageCameraActionButton(
label = "Wiele zdjęć", label = "Wiele zdjęć",
icon = Icons.Outlined.Collections, icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.navy, color = TppTheme.colors.forest,
compact = false, compact = false,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = onMultiplePhotos, onClick = onMultiplePhotos,
@@ -5667,6 +6006,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,
@@ -5676,9 +6016,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),
@@ -5725,6 +6069,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) {
@@ -5771,6 +6119,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 {
@@ -6004,6 +6355,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)
}, },
@@ -1478,19 +1493,60 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other") { fun uploadPhoto(
uri: Uri,
source: String,
metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
stage: String = "other",
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): Job? {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return null
if (!routeIsActiveToday(route, snapshot.selectedDate)) { if (!routeIsActiveToday(route, snapshot.selectedDate)) {
_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 return null
} }
viewModelScope.launch { val preparation = PreparingPhotoUpload(
_state.update { it.copy(error = null) } id = UUID.randomUUID().toString(),
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) } routeId = route.id,
.onFailure { throwable -> uri = uri,
_state.update { it.withApiError(throwable) } stage = stage,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
)
_state.update {
it.copy(
preparingPhotoUploads = it.preparingPhotoUploads + preparation,
error = null,
)
}
return viewModelScope.launch {
try {
runCatching {
photoUploadOutbox.enqueue(
routeId = route.id,
uri = uri,
source = source,
metadata = metadata,
stage = stage,
workflowVersion = snapshot.routeWorkflow?.version,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
)
} }
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
} finally {
_state.update {
it.copy(
preparingPhotoUploads = it.preparingPhotoUploads.filterNot { item ->
item.id == preparation.id
},
)
}
}
} }
} }
@@ -1505,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
@@ -1514,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,
) )
@@ -1559,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,
) )
@@ -1574,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)
@@ -1672,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,
@@ -1695,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() }
@@ -1714,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",
@@ -1725,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",
@@ -1746,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 {
@@ -1955,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)
@@ -12,14 +12,16 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -39,28 +41,32 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
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.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
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
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File import java.io.File
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.ui.theme.TppTheme import pl.firmatpp.kierowca.ui.theme.TppTheme
@Composable @Composable
internal fun MultiplePhotoCameraScreen( internal fun MultiplePhotoCameraScreen(
stage: String, stage: String,
onPhotoCaptured: (Uri) -> Unit, onPhotoCaptured: suspend (Uri) -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
val currentOnPhotoCaptured by rememberUpdatedState(onPhotoCaptured) val currentOnPhotoCaptured by rememberUpdatedState(onPhotoCaptured)
val cameraController = remember(context) { val cameraController = remember(context) {
LifecycleCameraController(context).apply { LifecycleCameraController(context).apply {
@@ -69,6 +75,7 @@ internal fun MultiplePhotoCameraScreen(
} }
} }
var capturedCount by remember { mutableIntStateOf(0) } var capturedCount 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) }
var cameraError by remember { mutableStateOf<String?>(null) } var cameraError by remember { mutableStateOf<String?>(null) }
@@ -87,7 +94,7 @@ internal fun MultiplePhotoCameraScreen(
} }
BackHandler { BackHandler {
if (!isCapturing) onClose() if (!isCapturing && pendingUploads == 0) onClose()
} }
Box( Box(
@@ -117,7 +124,7 @@ internal fun MultiplePhotoCameraScreen(
) { ) {
IconButton( IconButton(
onClick = onClose, onClick = onClose,
enabled = !isCapturing, enabled = !isCapturing && pendingUploads == 0,
) { ) {
Icon( Icon(
Icons.AutoMirrored.Outlined.ArrowBack, Icons.AutoMirrored.Outlined.ArrowBack,
@@ -132,7 +139,11 @@ internal fun MultiplePhotoCameraScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
text = "Wykonano: $capturedCount", text = if (pendingUploads > 0) {
"Wykonano: $capturedCount · zapisuję: $pendingUploads"
} else {
"Wykonano: $capturedCount"
},
color = Color.White.copy(alpha = 0.78f), color = Color.White.copy(alpha = 0.78f),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@@ -161,12 +172,11 @@ internal fun MultiplePhotoCameraScreen(
) )
} }
Row( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxWidth()
horizontalArrangement = Arrangement.SpaceBetween, .height(72.dp),
) { ) {
Spacer(Modifier.size(112.dp, 52.dp))
Button( Button(
onClick = { onClick = {
if (isCapturing || cameraUnavailable) return@Button if (isCapturing || cameraUnavailable) return@Button
@@ -182,7 +192,14 @@ internal fun MultiplePhotoCameraScreen(
isCapturing = false isCapturing = false
cameraError = null cameraError = null
capturedCount += 1 capturedCount += 1
currentOnPhotoCaptured(cameraFileUri(context, photoFile)) pendingUploads += 1
coroutineScope.launch {
try {
currentOnPhotoCaptured(cameraFileUri(context, photoFile))
} finally {
pendingUploads = (pendingUploads - 1).coerceAtLeast(0)
}
}
} }
override fun onError(exception: ImageCaptureException) { override fun onError(exception: ImageCaptureException) {
@@ -194,7 +211,9 @@ internal fun MultiplePhotoCameraScreen(
) )
}, },
enabled = !isCapturing && !cameraUnavailable, enabled = !isCapturing && !cameraUnavailable,
modifier = Modifier.size(78.dp), modifier = Modifier
.align(Alignment.Center)
.size(72.dp),
shape = CircleShape, shape = CircleShape,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color.White, containerColor = Color.White,
@@ -219,23 +238,39 @@ internal fun MultiplePhotoCameraScreen(
} }
Button( Button(
onClick = onClose, onClick = onClose,
enabled = !isCapturing, enabled = !isCapturing && pendingUploads == 0,
modifier = Modifier.size(width = 112.dp, height = 52.dp), modifier = Modifier
.align(Alignment.CenterEnd)
.width(104.dp)
.height(52.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = TppTheme.colors.forest, containerColor = TppTheme.colors.forest,
), ),
shape = RoundedCornerShape(6.dp), shape = RoundedCornerShape(6.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
) { ) {
Icon( if (pendingUploads > 0) {
Icons.Outlined.CheckCircle, CircularProgressIndicator(
contentDescription = null, modifier = Modifier.size(16.dp),
tint = Color.White, color = Color.White,
modifier = Modifier.size(20.dp), strokeWidth = 2.dp,
) )
} else {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
Text( Text(
text = "Gotowe", text = if (pendingUploads > 0) "Zapisuję" else "Gotowe",
color = Color.White, color = Color.White,
modifier = Modifier.padding(start = 6.dp), style = MaterialTheme.typography.labelMedium,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Clip,
modifier = Modifier.padding(start = 4.dp),
) )
} }
} }
@@ -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))