diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2f23db..0bff2bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 28 - versionName = "1.0.27" + versionCode = 29 + versionName = "1.0.28" setProperty("archivesBaseName", "pl.firmatpp.kierowca") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt index 4468d5a..8a4dab5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/ApiErrorMapper.kt @@ -20,6 +20,7 @@ data class ApiError( val retryable: Boolean, val code: String? = null, val statusCode: Int? = null, + val responseBody: String? = null, ) object ApiErrorMapper { @@ -48,10 +49,17 @@ object ApiErrorMapper { code = code, message = message, retryable = retryable, + responseBody = body, ) } - fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError { + fun mapProblem( + statusCode: Int, + code: String?, + message: String?, + retryable: Boolean?, + responseBody: String? = null, + ): ApiError { val kind = when (statusCode) { 401 -> ApiErrorKind.Auth 403 -> ApiErrorKind.Forbidden @@ -80,6 +88,7 @@ object ApiErrorMapper { retryable = resolvedRetryable, code = code, statusCode = statusCode, + responseBody = responseBody, ) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadFailureDetails.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadFailureDetails.kt new file mode 100644 index 0000000..d3b7070 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadFailureDetails.kt @@ -0,0 +1,39 @@ +package pl.firmatpp.kierowca.data.upload + +import pl.firmatpp.kierowca.data.ApiError +import pl.firmatpp.kierowca.data.ApiErrorKind + +object PhotoUploadFailureDetails { + fun fromApiError(error: ApiError): String { + val type = when (error.kind) { + ApiErrorKind.Network -> "błąd komunikacji" + ApiErrorKind.Server, + ApiErrorKind.RateLimited, + ApiErrorKind.Validation, + ApiErrorKind.Forbidden, + ApiErrorKind.Auth, + ApiErrorKind.Conflict, + -> "błąd serwera" + ApiErrorKind.Unknown -> "błąd aplikacji" + } + + return buildString { + appendLine("Typ: $type") + appendLine("Komunikat: ${error.message}") + error.statusCode?.let { appendLine("HTTP: $it") } + error.code?.takeIf { it.isNotBlank() }?.let { appendLine("Kod: $it") } + error.responseBody + ?.takeIf { it.isNotBlank() } + ?.let { + appendLine("Odpowiedź serwera:") + append(it.take(MAX_RESPONSE_CHARS)) + if (it.length > MAX_RESPONSE_CHARS) append("...") + } + }.trim() + } + + fun local(message: String): String = + "Typ: błąd aplikacji\nKomunikat: $message" + + private const val MAX_RESPONSE_CHARS = 1200 +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt index 25b5c52..fdf1295 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadWorker.kt @@ -22,11 +22,12 @@ class PhotoUploadWorker( val file = File(upload.localPath) if (!file.exists()) { + val message = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane." dao.updateStatus( clientRequestId = clientRequestId, status = PhotoUploadStatus.FailedPermanent.storageValue, progress = 0, - lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.", + lastError = PhotoUploadFailureDetails.local(message), attemptIncrement = 0, ) return Result.failure() @@ -68,7 +69,7 @@ class PhotoUploadWorker( clientRequestId = clientRequestId, status = status.storageValue, progress = 0, - lastError = error.message, + lastError = PhotoUploadFailureDetails.fromApiError(error), attemptIncrement = 0, ) 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 01bd065..d1d6050 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -56,6 +56,7 @@ import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocalShipping import androidx.compose.material.icons.outlined.Navigation @@ -612,6 +613,8 @@ private fun PhotoQueueScreen( @Composable private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploadEntity) -> Unit) { val status = upload.statusType + val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError) + var showFailureDetails by remember { mutableStateOf(false) } Card( colors = CardDefaults.cardColors(containerColor = Color.White), border = BorderStroke(1.dp, TppColors.Outline), @@ -632,8 +635,8 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold) Text( - upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label, - color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Muted, + upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label, + color = if (hasFailureDetails) TppColors.Error else TppColors.Muted, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelMedium, ) @@ -646,25 +649,75 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa ) } } - if (canRetryPhotoUpload(upload.status)) { - Button( - onClick = { onRetryUpload(upload) }, - colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), - shape = RoundedCornerShape(4.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - ) { - Icon( - Icons.Outlined.Refresh, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(6.dp)) - Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold) + if (hasFailureDetails || canRetryPhotoUpload(upload.status)) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + if (hasFailureDetails) { + IconButton( + onClick = { showFailureDetails = true }, + modifier = Modifier + .size(40.dp) + .background(TppColors.Surface, RoundedCornerShape(20.dp)) + .border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), + ) { + Icon( + Icons.Outlined.Info, + contentDescription = "Pokaż szczegóły błędu", + tint = TppColors.Error, + modifier = Modifier.size(20.dp), + ) + } + } + if (canRetryPhotoUpload(upload.status)) { + Button( + onClick = { onRetryUpload(upload) }, + colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + Icons.Outlined.Refresh, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold) + } + } } } } } + if (showFailureDetails) { + PhotoUploadFailureDialog( + details = upload.lastError.orEmpty(), + onDismiss = { showFailureDetails = false }, + ) + } +} + +@Composable +private fun PhotoUploadFailureDialog(details: String, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("Dlaczego nie wysłano zdjęcia?", color = TppColors.Ink, fontWeight = FontWeight.Bold) + }, + text = { + Text( + details, + color = TppColors.Ink, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()), + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Zamknij", color = TppColors.Forest, fontWeight = FontWeight.Bold) + } + }, + ) } @Composable @@ -1551,6 +1604,14 @@ private fun PendingPhotoTile( ) { val status = upload.statusType val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null + val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError) + var showFailureDetails by remember { mutableStateOf(false) } + if (showFailureDetails) { + PhotoUploadFailureDialog( + details = upload.lastError.orEmpty(), + onDismiss = { showFailureDetails = false }, + ) + } Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) { AsyncImage( model = File(upload.localPath), @@ -1586,6 +1647,25 @@ private fun PendingPhotoTile( ) } } + if (hasFailureDetails) { + IconButton( + onClick = { showFailureDetails = true }, + enabled = !isDeleting, + modifier = Modifier + .align(Alignment.TopStart) + .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.Info, + contentDescription = "Pokaż szczegóły błędu wysłania", + tint = TppColors.Error, + modifier = Modifier.size(20.dp), + ) + } + } if (canRetryPhotoUpload(upload.status)) { IconButton( onClick = { onRetry(upload) }, @@ -1610,8 +1690,8 @@ private fun PendingPhotoTile( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label, - color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Ink, + upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label, + color = if (hasFailureDetails) TppColors.Error else TppColors.Ink, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 18b18ed..8fd23bf 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -38,6 +38,9 @@ fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String fun canRetryPhotoUpload(status: String): Boolean = status == "FAILED_RETRYABLE" +fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean = + status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank() + fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? { if (upload.localPath.isBlank()) return null diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt index 74e1f3f..9a78d7a 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/data/ApiErrorMapperTest.kt @@ -39,4 +39,16 @@ class ApiErrorMapperTest { assertTrue(error.retryable) assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message) } + + @Test + fun keepsRawServerResponseForTroubleshooting() { + val body = """{"code":"PHOTO_SERVER_UNAVAILABLE","message":"Storage timeout","retryable":true}""" + val error = ApiErrorMapper.mapHttpStatus(503, body) + + assertEquals(ApiErrorKind.Server, error.kind) + assertEquals(503, error.statusCode) + assertEquals("PHOTO_SERVER_UNAVAILABLE", error.code) + assertEquals("Storage timeout", error.message) + assertEquals(body, error.responseBody) + } } diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadFailureDetailsTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadFailureDetailsTest.kt new file mode 100644 index 0000000..f107eee --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/PhotoUploadFailureDetailsTest.kt @@ -0,0 +1,44 @@ +package pl.firmatpp.kierowca.data.upload + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.firmatpp.kierowca.data.ApiError +import pl.firmatpp.kierowca.data.ApiErrorKind + +class PhotoUploadFailureDetailsTest { + @Test + fun describesServerFailureWithStatusCodeBackendCodeAndResponseBody() { + val details = PhotoUploadFailureDetails.fromApiError( + ApiError( + kind = ApiErrorKind.Server, + message = "Storage timeout", + retryable = true, + code = "PHOTO_STORAGE_FAILED", + statusCode = 503, + responseBody = """{"code":"PHOTO_STORAGE_FAILED","message":"Storage timeout"}""", + ), + ) + + assertTrue(details.contains("Typ: błąd serwera")) + assertTrue(details.contains("HTTP: 503")) + assertTrue(details.contains("Kod: PHOTO_STORAGE_FAILED")) + assertTrue(details.contains("Odpowiedź serwera:")) + assertTrue(details.contains("Storage timeout")) + } + + @Test + fun describesNetworkFailureWithoutHttpStatus() { + val details = PhotoUploadFailureDetails.fromApiError( + ApiError( + kind = ApiErrorKind.Network, + message = "Nie udało się połączyć z serwerem.", + retryable = true, + ), + ) + + assertTrue(details.contains("Typ: błąd komunikacji")) + assertTrue(details.contains("Nie udało się połączyć z serwerem.")) + assertFalse(details.contains("HTTP:")) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index 364e155..92c2533 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -111,6 +111,16 @@ class DriverUiRulesTest { assertFalse(canRetryPhotoUpload("CANCELLED")) } + @Test + fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() { + assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera")) + assertTrue(canShowPhotoUploadFailureDetails("FAILED_PERMANENT", "Typ: błąd aplikacji")) + assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", null)) + assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "")) + assertFalse(canShowPhotoUploadFailureDetails("UPLOADING", "Typ: błąd serwera")) + assertFalse(canShowPhotoUploadFailureDetails("CONFIRMED", "Typ: błąd serwera")) + } + @Test fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() { val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))