Compare commits

..
Author SHA1 Message Date
admin 90ca9775ef Wydaj aplikację kierowcy 1.0.77 (130) 2026-07-30 13:30:22 +02:00
admin 0e33564b95 Popraw zapis i usuwanie zdjęć kierowcy 2026-07-30 13:24:19 +02:00
5 changed files with 137 additions and 49 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 36
versionCode = 129
versionName = "1.0.76"
versionCode = 130
versionName = "1.0.77"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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<List<PhotoUploadEntity>> =
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) {
@@ -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<Uri?>(null) }
var pendingCameraMode by remember { mutableStateOf<RouteStageCameraMode?>(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,
@@ -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 ->
@@ -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<String?>(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
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),
) {
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(20.dp),
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),
)
}
}