diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 712824b..e417714 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,8 +34,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 45 - versionName = "1.0.43" + versionCode = 46 + versionName = "1.0.44" setProperty("archivesBaseName", "pl.firmatpp.kierowca") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 c764aad..e563a7d 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -2490,13 +2490,44 @@ private fun PhotoTile( onDeletePhoto: (RoutePhotoDto) -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier.aspectRatio(1f)) { + val hasPhoto = photo.url.isNotBlank() + var imageLoading by remember(photo.url) { mutableStateOf(hasPhoto) } + var imageError by remember(photo.url) { mutableStateOf(!hasPhoto) } + Box(modifier.aspectRatio(1f).background(TppTheme.colors.panel, RoundedCornerShape(4.dp))) { AsyncImage( model = imageRequest(photo.url, imageAuthHeader), contentDescription = "Zdjecie ladunku", contentScale = ContentScale.Crop, + onLoading = { + imageLoading = true + imageError = false + }, + onSuccess = { + imageLoading = false + imageError = false + }, + onError = { + imageLoading = false + imageError = true + }, modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) }, ) + AnimatedVisibility( + visible = shouldShowPhotoTileLoadingState(hasPhoto, imageLoading, imageError), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center), + ) { + PhotoTileLoadingOverlay() + } + AnimatedVisibility( + visible = shouldShowPhotoTileErrorState(hasPhoto, imageError), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center), + ) { + PhotoTileErrorOverlay() + } if (isDeleting) { Box( Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)), @@ -2546,14 +2577,45 @@ private fun PendingPhotoTile( onDismiss = { showFailureDetails = false }, ) } + val hasPhoto = upload.localPath.isNotBlank() + var imageLoading by remember(upload.localPath) { mutableStateOf(hasPhoto) } + var imageError by remember(upload.localPath) { mutableStateOf(!hasPhoto) } Box(modifier.aspectRatio(1f).background(TppTheme.colors.panel, RoundedCornerShape(4.dp))) { AsyncImage( model = File(upload.localPath), contentDescription = "Zdjęcie oczekujące na zapis", contentScale = ContentScale.Crop, + onLoading = { + imageLoading = true + imageError = false + }, + onSuccess = { + imageLoading = false + imageError = false + }, + onError = { + imageLoading = false + imageError = true + }, modifier = Modifier.fillMaxSize().clickable { onPhoto(upload) }, ) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f))) + AnimatedVisibility( + visible = shouldShowPhotoTileLoadingState(hasPhoto, imageLoading, imageError), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center), + ) { + PhotoTileLoadingOverlay() + } + AnimatedVisibility( + visible = shouldShowPhotoTileErrorState(hasPhoto, imageError), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center), + ) { + PhotoTileErrorOverlay() + } if (isDeleting) { Box( Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)), @@ -2642,6 +2704,50 @@ private fun PendingPhotoTile( } } +@Composable +private fun PhotoTileLoadingOverlay() { + Box( + modifier = Modifier + .size(54.dp) + .background(TppTheme.colors.card.copy(alpha = 0.94f), RoundedCornerShape(8.dp)) + .border(1.dp, TppTheme.colors.outline.copy(alpha = 0.7f), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(25.dp), + color = TppTheme.colors.forest, + trackColor = TppTheme.colors.outline.copy(alpha = 0.32f), + strokeWidth = 2.5.dp, + ) + } +} + +@Composable +private fun PhotoTileErrorOverlay() { + Column( + modifier = Modifier + .background(TppTheme.colors.card.copy(alpha = 0.96f), RoundedCornerShape(8.dp)) + .border(1.dp, TppTheme.colors.error.copy(alpha = 0.36f), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + "Błąd", + color = TppTheme.colors.error, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelMedium, + ) + Text( + "miniatury", + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.labelSmall, + ) + } +} + @Composable private fun EmptyPhotoState() { Box( 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 8585456..d0d5412 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -65,6 +65,12 @@ fun shouldShowPhotoPreviewLoadingState(hasPhoto: Boolean, isLoading: Boolean, is fun shouldShowPhotoPreviewErrorState(hasPhoto: Boolean, isError: Boolean): Boolean = hasPhoto && isError +fun shouldShowPhotoTileLoadingState(hasPhoto: Boolean, isLoading: Boolean, isError: Boolean): Boolean = + hasPhoto && isLoading && !isError + +fun shouldShowPhotoTileErrorState(hasPhoto: Boolean, isError: Boolean): Boolean = + hasPhoto && isError + fun authBrandBannerHeightDp(screenHeightDp: Int): Int = when { screenHeightDp < 640 -> 118 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 fb1bb0c..e9c6b95 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -73,6 +73,18 @@ class DriverUiRulesTest { assertFalse(shouldShowPhotoPreviewErrorState(hasPhoto = false, isError = true)) } + @Test + fun keepsPhotoTileFeedbackVisibleUntilThumbnailLoadsOrFails() { + assertTrue(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = true, isError = false)) + assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = false, isError = false)) + assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = true, isError = true)) + assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = false, isLoading = true, isError = false)) + + assertTrue(shouldShowPhotoTileErrorState(hasPhoto = true, isError = true)) + assertFalse(shouldShowPhotoTileErrorState(hasPhoto = true, isError = false)) + assertFalse(shouldShowPhotoTileErrorState(hasPhoto = false, isError = true)) + } + @Test fun usesShorterChromeOnCompactScreens() { assertEquals(118, authBrandBannerHeightDp(600))