Compare commits

..
1 Commits
Author SHA1 Message Date
admin 06557e77b8 Dodaj usuwanie świeżo zapisanych zdjęć 2026-07-01 02:43:52 +02:00
6 changed files with 107 additions and 11 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 16 versionCode = 17
versionName = "1.0.15" versionName = "1.0.16"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"") buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
@@ -69,6 +69,13 @@ class PhotoUploadOutbox(
enqueueWorker(clientRequestId) enqueueWorker(clientRequestId)
} }
suspend fun discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) { suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct() val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct()
if (confirmedRequestIds.isEmpty()) return if (confirmedRequestIds.isEmpty()) return
@@ -179,7 +179,16 @@ fun DriverApp(viewModel: DriverViewModel) {
onRoute = viewModel::openRoute, onRoute = viewModel::openRoute,
) )
DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout) DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout)
DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto, viewModel::deletePhoto, viewModel::retryPhotoUpload, viewModel::refreshSelectedRoute) DriverScreen.Detail -> DetailScreen(
state,
viewModel::back,
viewModel::uploadPhoto,
viewModel::openPhoto,
viewModel::deletePhoto,
viewModel::deleteConfirmedUpload,
viewModel::retryPhotoUpload,
viewModel::refreshSelectedRoute,
)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back) DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
} }
@@ -738,6 +747,7 @@ private fun DetailScreen(
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
@@ -784,6 +794,7 @@ private fun DetailScreen(
canManagePhotos = canManagePhotos, canManagePhotos = canManagePhotos,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto, onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload, onRetryUpload = onRetryUpload,
onCamera = { onCamera = {
val newUri = createCameraUri(context) val newUri = createCameraUri(context)
@@ -975,6 +986,7 @@ private fun CargoDocumentationSection(
canManagePhotos: Boolean, canManagePhotos: Boolean,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit,
onCamera: () -> Unit, onCamera: () -> Unit,
onGallery: () -> Unit, onGallery: () -> Unit,
@@ -997,7 +1009,7 @@ private fun CargoDocumentationSection(
} }
} }
} }
PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onRetryUpload) PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onDeleteUpload, onRetryUpload)
} }
} }
@@ -1023,9 +1035,10 @@ private fun PhotoGrid(
imageAuthHeader: String?, imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit,
) { ) {
var photoPendingDelete by remember { mutableStateOf<RoutePhotoDto?>(null) } var pendingDelete by remember { mutableStateOf<PhotoDeleteTarget?>(null) }
val items = (uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) }) val items = (uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) })
.sortedByDescending { it.sortEpochMillis } .sortedByDescending { it.sortEpochMillis }
val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size) val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size)
@@ -1057,11 +1070,13 @@ private fun PhotoGrid(
imageAuthHeader = imageAuthHeader, imageAuthHeader = imageAuthHeader,
isDeleting = item.photo.id in deletingPhotoIds, isDeleting = item.photo.id in deletingPhotoIds,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = { photoPendingDelete = it }, onDeletePhoto = { pendingDelete = PhotoDeleteTarget.Server(it) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
is PhotoGridItem.Upload -> PendingPhotoTile( is PhotoGridItem.Upload -> PendingPhotoTile(
upload = item.upload, upload = item.upload,
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
onRetry = onRetryUpload, onRetry = onRetryUpload,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -1077,23 +1092,26 @@ private fun PhotoGrid(
} }
} }
photoPendingDelete?.let { photo -> pendingDelete?.let { target ->
AlertDialog( AlertDialog(
onDismissRequest = { photoPendingDelete = null }, onDismissRequest = { pendingDelete = null },
title = { Text("Usunąć zdjęcie?", color = TppColors.Ink, fontWeight = FontWeight.Bold) }, title = { Text("Usunąć zdjęcie?", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = { Text("Czy na pewno chcesz usunąć to zdjęcie?", color = TppColors.Muted) }, text = { Text("Czy na pewno chcesz usunąć to zdjęcie?", color = TppColors.Muted) },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
photoPendingDelete = null pendingDelete = null
onDeletePhoto(photo) when (target) {
is PhotoDeleteTarget.Server -> onDeletePhoto(target.photo)
is PhotoDeleteTarget.Upload -> onDeleteUpload(target.upload)
}
}, },
) { ) {
Text("Usuń", color = TppColors.Error, fontWeight = FontWeight.Bold) Text("Usuń", color = TppColors.Error, fontWeight = FontWeight.Bold)
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { photoPendingDelete = null }) { TextButton(onClick = { pendingDelete = null }) {
Text("Anuluj", color = TppColors.Muted) Text("Anuluj", color = TppColors.Muted)
} }
}, },
@@ -1109,6 +1127,11 @@ 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)
} }
private sealed class PhotoDeleteTarget {
data class Server(val photo: RoutePhotoDto) : PhotoDeleteTarget()
data class Upload(val upload: PhotoUploadEntity) : PhotoDeleteTarget()
}
@Composable @Composable
private fun PhotoTile( private fun PhotoTile(
photo: RoutePhotoDto, photo: RoutePhotoDto,
@@ -1158,10 +1181,13 @@ private fun PhotoTile(
@Composable @Composable
private fun PendingPhotoTile( private fun PendingPhotoTile(
upload: PhotoUploadEntity, upload: PhotoUploadEntity,
isDeleting: Boolean,
onDelete: (PhotoUploadEntity) -> Unit,
onRetry: (PhotoUploadEntity) -> Unit, onRetry: (PhotoUploadEntity) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val status = upload.statusType val status = upload.statusType
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) { Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) {
AsyncImage( AsyncImage(
model = File(upload.localPath), model = File(upload.localPath),
@@ -1170,6 +1196,33 @@ private fun PendingPhotoTile(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f))) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f)))
if (isDeleting) {
Box(
Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)),
contentAlignment = Alignment.Center,
) {
Text("Usuwam", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
}
if (canDeleteServerPhoto) {
IconButton(
onClick = { onDelete(upload) },
enabled = !isDeleting,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(40.dp)
.background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
.border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
) {
Icon(
Icons.Outlined.Delete,
contentDescription = "Usuń zdjęcie",
tint = TppColors.Error,
modifier = Modifier.size(19.dp),
)
}
}
Column( Column(
Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Color.White.copy(alpha = 0.94f)).padding(10.dp), Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Color.White.copy(alpha = 0.94f)).padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -17,6 +17,9 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
?: parseIsoOffsetEpochMillis(takenAt) ?: parseIsoOffsetEpochMillis(takenAt)
?: fallback ?: fallback
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
private fun parseIsoOffsetEpochMillis(value: String?): Long? = private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let { value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
@@ -210,6 +210,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun deleteConfirmedUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
runCatching {
repository.deletePhoto(serverPhotoId)
photoUploadOutbox.discard(upload.clientRequestId)
val response = repository.route(route.id)
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { item -> if (item.id == route.id) response.route else item },
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) }
}
}
fun openPhoto(photo: RoutePhotoDto) { fun openPhoto(photo: RoutePhotoDto) {
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) } _state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
} }
@@ -60,4 +60,12 @@ class DriverUiRulesTest {
) )
assertEquals(fallback, routePhotoSortEpochMillis(createdAt = null, takenAt = null, fallback = fallback)) assertEquals(fallback, routePhotoSortEpochMillis(createdAt = null, takenAt = null, fallback = fallback))
} }
@Test
fun allowsDeleteIconForConfirmedLocalUploadWithServerPhotoId() {
assertEquals("42", confirmedUploadServerPhotoId("CONFIRMED", "42"))
assertEquals(null, confirmedUploadServerPhotoId("CONFIRMED", ""))
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
}
} }