From 0e33564b95f5cb922a60b53a4f8f8b88c40f2790 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 30 Jul 2026 13:24:19 +0200 Subject: [PATCH] =?UTF-8?q?Popraw=20zapis=20i=20usuwanie=20zdj=C4=99=C4=87?= =?UTF-8?q?=20kierowcy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kierowca/data/upload/PhotoUploadOutbox.kt | 12 ++- .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 78 +++++++++++++----- .../firmatpp/kierowca/ui/DriverViewModel.kt | 13 ++- .../kierowca/ui/MultiplePhotoCameraScreen.kt | 79 +++++++++++++------ 4 files changed, 135 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt index 3488daf..90f8e85 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt @@ -13,7 +13,9 @@ import java.io.File import java.security.MessageDigest import java.util.UUID import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto @@ -29,7 +31,13 @@ class PhotoUploadOutbox( fun observeQueuedUploads(): Flow> = 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", + ): PhotoUploadEntity = withContext(Dispatchers.IO) { val clientRequestId = UUID.randomUUID().toString() val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val extension = when (mimeType) { @@ -58,7 +66,7 @@ class PhotoUploadOutbox( dao.upsert(upload) enqueueWorker(clientRequestId) - return upload + upload } suspend fun retry(clientRequestId: String) { diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 9b205a5..2aeb3cd 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -62,7 +62,6 @@ import androidx.compose.material.icons.outlined.CalendarToday import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CloudUpload 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.Factory import androidx.compose.material.icons.outlined.Delete @@ -470,6 +469,9 @@ fun DriverApp( locationPermissionGranted = locationPermissionGranted, onBack = viewModel::back, onPhoto = viewModel::openPhoto, + onDeletePhoto = viewModel::deletePhoto, + onDeleteUpload = viewModel::deletePhotoUpload, + onRetryUpload = viewModel::retryPhotoUpload, onRefresh = viewModel::refreshSelectedRoute, onStartRoute = openStartRoute, onFinishRoute = viewModel::openFinishRoute, @@ -489,8 +491,9 @@ fun DriverApp( onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, onNotesChange = viewModel::updateRouteStageNotes, - onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") }, + onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading")?.join() }, onPhoto = viewModel::openPhoto, + onDeletePhoto = viewModel::deletePhoto, onDeleteUpload = viewModel::deletePhotoUpload, onRetryUpload = viewModel::retryPhotoUpload, onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, @@ -510,6 +513,7 @@ fun DriverApp( onNotesChange = viewModel::updateRouteStageNotes, onUpload = { _, _, _ -> }, onPhoto = viewModel::openPhoto, + onDeletePhoto = viewModel::deletePhoto, onDeleteUpload = viewModel::deletePhotoUpload, onRetryUpload = viewModel::retryPhotoUpload, onSubmit = viewModel::submitStartRoute, @@ -527,8 +531,9 @@ fun DriverApp( onBack = viewModel::back, onWeightChange = viewModel::updateRouteStageWeight, onNotesChange = viewModel::updateRouteStageNotes, - onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") }, + onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading")?.join() }, onPhoto = viewModel::openPhoto, + onDeletePhoto = viewModel::deletePhoto, onDeleteUpload = viewModel::deletePhotoUpload, onRetryUpload = viewModel::retryPhotoUpload, onSubmit = viewModel::submitFinishRoute, @@ -4422,8 +4427,9 @@ private fun RouteStageScreen( onBack: () -> Unit, onWeightChange: (String) -> Unit, onNotesChange: (String) -> Unit, - onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, + onUpload: suspend (Uri, String, PhotoUploadMetadata) -> Unit, onPhoto: (RoutePhotoDto) -> Unit, + onDeletePhoto: (RoutePhotoDto) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit, onSubmit: () -> Unit, @@ -4433,6 +4439,7 @@ private fun RouteStageScreen( var cameraUri by remember { mutableStateOf(null) } var pendingCameraMode by remember { mutableStateOf(null) } var showMultiplePhotoCamera by remember(stage) { mutableStateOf(false) } + var multiplePhotoLocationMetadata by remember(stage) { mutableStateOf(PhotoUploadMetadata()) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) } val route = state.displaySelectedRoute @@ -4486,6 +4493,13 @@ private fun RouteStageScreen( } cameraUri = null } + val openMultiplePhotoCamera = { + multiplePhotoLocationMetadata = PhotoUploadMetadata() + showMultiplePhotoCamera = true + coroutineScope.launch { + multiplePhotoLocationMetadata = cameraPhotoMetadata(context).copy(takenAt = null) + } + } val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val requestedMode = pendingCameraMode val pendingUri = cameraUri @@ -4504,7 +4518,7 @@ private fun RouteStageScreen( if (pendingUri != null) cameraLauncher.launch(pendingUri) } RouteStageCameraMode.Multiple -> { - showMultiplePhotoCamera = true + openMultiplePhotoCamera() } null -> Unit } @@ -4514,16 +4528,22 @@ private fun RouteStageScreen( pendingCameraMode = null } val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata()) + if (uri != null) { + coroutineScope.launch { + onUpload(uri, "gallery", PhotoUploadMetadata()) + } + } } if (showMultiplePhotoCamera) { MultiplePhotoCameraScreen( stage = stage, onPhotoCaptured = { uri -> - coroutineScope.launch { - onUpload(uri, "camera", cameraPhotoMetadata(context)) - } + onUpload( + uri, + "camera", + multiplePhotoLocationMetadata.copy(takenAt = Instant.now().toString()), + ) }, onClose = { showMultiplePhotoCamera = false @@ -4636,7 +4656,7 @@ private fun RouteStageScreen( ) if (missingPermissions.isEmpty()) { pendingCameraMode = null - showMultiplePhotoCamera = true + openMultiplePhotoCamera() } else { cameraPermissionLauncher.launch(missingPermissions) } @@ -4658,7 +4678,7 @@ private fun RouteStageScreen( deletingPhotoIds = state.deletingPhotoIds, imageAuthHeader = state.imageAuthHeader, onPhoto = onPhoto, - onDeletePhoto = {}, + onDeletePhoto = onDeletePhoto, onDeleteUpload = onDeleteUpload, onRetryUpload = onRetryUpload, ) @@ -4865,6 +4885,9 @@ private fun DetailScreen( locationPermissionGranted: Boolean, onBack: () -> Unit, onPhoto: (RoutePhotoDto) -> Unit, + onDeletePhoto: (RoutePhotoDto) -> Unit, + onDeleteUpload: (PhotoUploadEntity) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, onRefresh: () -> Unit, onStartRoute: () -> Unit, onFinishRoute: () -> Unit, @@ -4923,6 +4946,9 @@ private fun DetailScreen( loadingWeightPending = projection.loadingWeightPending, unloadingWeightPending = projection.unloadingWeightPending, onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, ) } item { ErrorText(state.error) } @@ -5391,6 +5417,9 @@ private fun CargoDocumentationSection( loadingWeightPending: Boolean, unloadingWeightPending: Boolean, onPhoto: (RoutePhotoDto) -> Unit, + onDeletePhoto: (RoutePhotoDto) -> Unit, + onDeleteUpload: (PhotoUploadEntity) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, ) { val loadingPhotos = routePhotosForStage(route.photos, "loading") val unloadingPhotos = routePhotosForStage(route.photos, "unloading") @@ -5415,6 +5444,9 @@ private fun CargoDocumentationSection( imageAuthHeader = imageAuthHeader, weightPending = loadingWeightPending, onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, ) RouteStageDocumentationBlock( title = "Rozładunek", @@ -5426,6 +5458,9 @@ private fun CargoDocumentationSection( imageAuthHeader = imageAuthHeader, weightPending = unloadingWeightPending, onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, ) if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) { RouteStageDocumentationBlock( @@ -5438,6 +5473,9 @@ private fun CargoDocumentationSection( imageAuthHeader = imageAuthHeader, weightPending = false, onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, ) } } @@ -5454,6 +5492,9 @@ private fun RouteStageDocumentationBlock( imageAuthHeader: String?, weightPending: Boolean = false, onPhoto: (RoutePhotoDto) -> Unit, + onDeletePhoto: (RoutePhotoDto) -> Unit, + onDeleteUpload: (PhotoUploadEntity) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, ) { val count = visiblePhotoAttachmentCount(photos.size, uploads.size) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -5536,10 +5577,9 @@ private fun RouteStageDocumentationBlock( deletingPhotoIds = deletingPhotoIds, imageAuthHeader = imageAuthHeader, onPhoto = onPhoto, - onDeletePhoto = {}, - onDeleteUpload = {}, - onRetryUpload = {}, - readOnly = true, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, ) } } @@ -5569,8 +5609,8 @@ private fun RouteStageCameraActions( ) RouteStageCameraActionButton( label = "Wiele zdjęć", - icon = Icons.Outlined.Collections, - color = TppTheme.colors.navy, + icon = Icons.Outlined.CameraAlt, + color = TppTheme.colors.forest, compact = true, modifier = Modifier.weight(1f), onClick = onMultiplePhotos, @@ -5588,8 +5628,8 @@ private fun RouteStageCameraActions( ) RouteStageCameraActionButton( label = "Wiele zdjęć", - icon = Icons.Outlined.Collections, - color = TppTheme.colors.navy, + icon = Icons.Outlined.CameraAlt, + color = TppTheme.colors.forest, compact = false, modifier = Modifier.fillMaxWidth(), onClick = onMultiplePhotos, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index eb65e0f..09a3326 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -1478,14 +1478,19 @@ 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", + ): Job? { val snapshot = _state.value - val route = snapshot.selectedRoute ?: return + val route = snapshot.selectedRoute ?: return null if (!routeIsActiveToday(route, snapshot.selectedDate)) { _state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") } - return + return null } - viewModelScope.launch { + return viewModelScope.launch { _state.update { it.copy(error = null) } runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) } .onFailure { throwable -> diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt index 6b82c2f..7f5802d 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/MultiplePhotoCameraScreen.kt @@ -12,14 +12,16 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -39,28 +41,32 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.lifecycle.compose.LocalLifecycleOwner import java.io.File +import kotlinx.coroutines.launch import pl.firmatpp.kierowca.ui.theme.TppTheme @Composable internal fun MultiplePhotoCameraScreen( stage: String, - onPhotoCaptured: (Uri) -> Unit, + onPhotoCaptured: suspend (Uri) -> Unit, onClose: () -> Unit, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val coroutineScope = rememberCoroutineScope() val currentOnPhotoCaptured by rememberUpdatedState(onPhotoCaptured) val cameraController = remember(context) { LifecycleCameraController(context).apply { @@ -69,6 +75,7 @@ internal fun MultiplePhotoCameraScreen( } } var capturedCount by remember { mutableIntStateOf(0) } + var pendingUploads by remember { mutableIntStateOf(0) } var isCapturing by remember { mutableStateOf(false) } var cameraUnavailable by remember { mutableStateOf(false) } var cameraError by remember { mutableStateOf(null) } @@ -87,7 +94,7 @@ internal fun MultiplePhotoCameraScreen( } BackHandler { - if (!isCapturing) onClose() + if (!isCapturing && pendingUploads == 0) onClose() } Box( @@ -117,7 +124,7 @@ internal fun MultiplePhotoCameraScreen( ) { IconButton( onClick = onClose, - enabled = !isCapturing, + enabled = !isCapturing && pendingUploads == 0, ) { Icon( Icons.AutoMirrored.Outlined.ArrowBack, @@ -132,7 +139,11 @@ internal fun MultiplePhotoCameraScreen( style = MaterialTheme.typography.titleMedium, ) Text( - text = "Wykonano: $capturedCount", + text = if (pendingUploads > 0) { + "Wykonano: $capturedCount · zapisuję: $pendingUploads" + } else { + "Wykonano: $capturedCount" + }, color = Color.White.copy(alpha = 0.78f), style = MaterialTheme.typography.bodyMedium, ) @@ -161,12 +172,11 @@ internal fun MultiplePhotoCameraScreen( ) } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp), ) { - Spacer(Modifier.size(112.dp, 52.dp)) Button( onClick = { if (isCapturing || cameraUnavailable) return@Button @@ -182,7 +192,14 @@ internal fun MultiplePhotoCameraScreen( isCapturing = false cameraError = null 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) { @@ -194,7 +211,9 @@ internal fun MultiplePhotoCameraScreen( ) }, enabled = !isCapturing && !cameraUnavailable, - modifier = Modifier.size(78.dp), + modifier = Modifier + .align(Alignment.Center) + .size(72.dp), shape = CircleShape, colors = ButtonDefaults.buttonColors( containerColor = Color.White, @@ -219,23 +238,39 @@ internal fun MultiplePhotoCameraScreen( } Button( onClick = onClose, - enabled = !isCapturing, - modifier = Modifier.size(width = 112.dp, height = 52.dp), + enabled = !isCapturing && pendingUploads == 0, + modifier = Modifier + .align(Alignment.CenterEnd) + .width(104.dp) + .height(52.dp), colors = ButtonDefaults.buttonColors( containerColor = TppTheme.colors.forest, ), shape = RoundedCornerShape(6.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), ) { - Icon( - Icons.Outlined.CheckCircle, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp), - ) + if (pendingUploads > 0) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp, + ) + } else { + Icon( + Icons.Outlined.CheckCircle, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + } Text( - text = "Gotowe", + text = if (pendingUploads > 0) "Zapisuję" else "Gotowe", 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), ) } }