Compare commits

..
Author SHA1 Message Date
admin 1834763f19 Add route detail photo tile loaders 2026-07-05 23:29:49 +02:00
admin 62c0469669 Add photo preview loading state 2026-07-05 23:14:07 +02:00
4 changed files with 249 additions and 10 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 44 versionCode = 46
versionName = "1.0.42" versionName = "1.0.44"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -12,6 +12,9 @@ import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
@@ -2487,13 +2490,44 @@ private fun PhotoTile(
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
modifier: Modifier = Modifier, 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( AsyncImage(
model = imageRequest(photo.url, imageAuthHeader), model = imageRequest(photo.url, imageAuthHeader),
contentDescription = "Zdjecie ladunku", contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
onLoading = {
imageLoading = true
imageError = false
},
onSuccess = {
imageLoading = false
imageError = false
},
onError = {
imageLoading = false
imageError = true
},
modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) }, 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) { if (isDeleting) {
Box( Box(
Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)), Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)),
@@ -2543,14 +2577,45 @@ private fun PendingPhotoTile(
onDismiss = { showFailureDetails = false }, 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))) { Box(modifier.aspectRatio(1f).background(TppTheme.colors.panel, RoundedCornerShape(4.dp))) {
AsyncImage( AsyncImage(
model = File(upload.localPath), model = File(upload.localPath),
contentDescription = "Zdjęcie oczekujące na zapis", contentDescription = "Zdjęcie oczekujące na zapis",
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
onLoading = {
imageLoading = true
imageError = false
},
onSuccess = {
imageLoading = false
imageError = false
},
onError = {
imageLoading = false
imageError = true
},
modifier = Modifier.fillMaxSize().clickable { onPhoto(upload) }, modifier = Modifier.fillMaxSize().clickable { onPhoto(upload) },
) )
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f))) 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) { if (isDeleting) {
Box( Box(
Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)), Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)),
@@ -2639,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 @Composable
private fun EmptyPhotoState() { private fun EmptyPhotoState() {
Box( Box(
@@ -2675,22 +2784,116 @@ private fun DriverRouteDto.distanceLabel(): String =
@Composable @Composable
private fun PhotoScreen(state: DriverUiState, onBack: () -> Unit) { private fun PhotoScreen(state: DriverUiState, onBack: () -> Unit) {
val photo = state.selectedPhoto val photo = state.selectedPhoto
val hasPhoto = !photo?.url.isNullOrBlank()
var imageLoading by remember(photo?.url) { mutableStateOf(hasPhoto) }
var imageError by remember(photo?.url) { mutableStateOf(!hasPhoto) }
val viewerBackground = Color(0xFF050805)
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
modifier = Modifier.statusBarsPadding(), modifier = Modifier.statusBarsPadding(),
title = { Text("Podglad zdjecia") }, title = { Text("Podglad zdjecia") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Black, titleContentColor = Color.White, navigationIconContentColor = Color.White), colors = TopAppBarDefaults.topAppBarColors(
containerColor = viewerBackground,
titleContentColor = Color.White,
navigationIconContentColor = Color.White,
),
) )
}, },
containerColor = Color.Black, containerColor = viewerBackground,
) { padding -> ) { padding ->
AsyncImage( Box(Modifier.fillMaxSize().padding(padding).background(viewerBackground)) {
model = imageRequest(photo?.url, state.imageAuthHeader), AsyncImage(
contentDescription = "Zdjecie ladunku", model = imageRequest(photo?.url, state.imageAuthHeader),
contentScale = ContentScale.Fit, contentDescription = "Zdjecie ladunku",
modifier = Modifier.fillMaxSize().padding(padding), contentScale = ContentScale.Fit,
onLoading = {
imageLoading = true
imageError = false
},
onSuccess = {
imageLoading = false
imageError = false
},
onError = {
imageLoading = false
imageError = true
},
modifier = Modifier.fillMaxSize(),
)
AnimatedVisibility(
visible = shouldShowPhotoPreviewLoadingState(hasPhoto, imageLoading, imageError),
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center),
) {
PhotoPreviewLoadingOverlay()
}
AnimatedVisibility(
visible = shouldShowPhotoPreviewErrorState(hasPhoto, imageError),
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center),
) {
PhotoPreviewErrorOverlay()
}
}
}
}
@Composable
private fun PhotoPreviewLoadingOverlay() {
Column(
modifier = Modifier
.background(Color(0xFF111710).copy(alpha = 0.94f), RoundedCornerShape(8.dp))
.border(1.dp, Color(0xFF609830).copy(alpha = 0.42f), RoundedCornerShape(8.dp))
.padding(horizontal = 24.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(36.dp),
color = Color(0xFF8EC45D),
trackColor = Color.White.copy(alpha = 0.12f),
strokeWidth = 3.dp,
)
Text(
"Ładuję zdjęcie",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
"Pobieram plik z serwera",
color = Color.White.copy(alpha = 0.72f),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
)
}
}
@Composable
private fun PhotoPreviewErrorOverlay() {
Column(
modifier = Modifier
.background(Color(0xFF171111).copy(alpha = 0.94f), RoundedCornerShape(8.dp))
.border(1.dp, TppTheme.colors.error.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
.padding(horizontal = 24.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Nie udało się wczytać zdjęcia",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
"Wróć i spróbuj ponownie",
color = Color.White.copy(alpha = 0.72f),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
) )
} }
} }
@@ -59,6 +59,18 @@ private fun polishWeekdayPhrase(day: DayOfWeek): String =
fun inlinePhotoGridRows(photoCount: Int): Int = fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2 if (photoCount <= 0) 0 else (photoCount + 1) / 2
fun shouldShowPhotoPreviewLoadingState(hasPhoto: Boolean, isLoading: Boolean, isError: Boolean): Boolean =
hasPhoto && isLoading && !isError
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 = fun authBrandBannerHeightDp(screenHeightDp: Int): Int =
when { when {
screenHeightDp < 640 -> 118 screenHeightDp < 640 -> 118
@@ -61,6 +61,30 @@ class DriverUiRulesTest {
assertEquals(3, inlinePhotoGridRows(5)) assertEquals(3, inlinePhotoGridRows(5))
} }
@Test
fun keepsPhotoPreviewFeedbackVisibleUntilImageLoadsOrFails() {
assertTrue(shouldShowPhotoPreviewLoadingState(hasPhoto = true, isLoading = true, isError = false))
assertFalse(shouldShowPhotoPreviewLoadingState(hasPhoto = true, isLoading = false, isError = false))
assertFalse(shouldShowPhotoPreviewLoadingState(hasPhoto = true, isLoading = true, isError = true))
assertFalse(shouldShowPhotoPreviewLoadingState(hasPhoto = false, isLoading = true, isError = false))
assertTrue(shouldShowPhotoPreviewErrorState(hasPhoto = true, isError = true))
assertFalse(shouldShowPhotoPreviewErrorState(hasPhoto = true, isError = false))
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 @Test
fun usesShorterChromeOnCompactScreens() { fun usesShorterChromeOnCompactScreens() {
assertEquals(118, authBrandBannerHeightDp(600)) assertEquals(118, authBrandBannerHeightDp(600))