Compare commits

...
2 Commits
Author SHA1 Message Date
admin 62c0469669 Add photo preview loading state 2026-07-05 23:14:07 +02:00
admin 3d6e1624e9 Fix leave request detail refresh navigation 2026-07-05 11:39:18 +02:00
6 changed files with 182 additions and 20 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 43
versionName = "1.0.41"
versionCode = 45
versionName = "1.0.43"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -12,6 +12,9 @@ import android.location.LocationManager
import android.net.Uri
import android.os.Build
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.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
@@ -2675,22 +2678,116 @@ private fun DriverRouteDto.distanceLabel(): String =
@Composable
private fun PhotoScreen(state: DriverUiState, onBack: () -> Unit) {
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(
topBar = {
TopAppBar(
modifier = Modifier.statusBarsPadding(),
title = { Text("Podglad zdjecia") },
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 ->
AsyncImage(
model = imageRequest(photo?.url, state.imageAuthHeader),
contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().padding(padding),
Box(Modifier.fillMaxSize().padding(padding).background(viewerBackground)) {
AsyncImage(
model = imageRequest(photo?.url, state.imageAuthHeader),
contentDescription = "Zdjecie ladunku",
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,12 @@ private fun polishWeekdayPhrase(day: DayOfWeek): String =
fun inlinePhotoGridRows(photoCount: Int): Int =
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 authBrandBannerHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 118
@@ -87,6 +87,21 @@ private fun DriverUiState.withApiError(throwable: Throwable): DriverUiState {
)
}
internal fun DriverUiState.withLoadedLeaveRequests(
requests: List<DriverLeaveRequestDto>,
navigateToList: Boolean,
): DriverUiState {
val refreshedSelectedRequest = selectedLeaveRequest?.let { selected ->
requests.firstOrNull { request -> request.id == selected.id } ?: selected
}
return copy(
screen = if (navigateToList) DriverScreen.LeaveRequests else screen,
leaveRequests = requests,
selectedLeaveRequest = refreshedSelectedRequest,
error = null,
)
}
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application)
@@ -346,27 +361,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true)
loadLeaveRequests(showLoading = true, navigateToList = true)
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false)
loadLeaveRequests(showLoading = false, navigateToList = false)
}
private fun loadLeaveRequests(showLoading: Boolean) {
private fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
_state.update {
it.copy(
screen = DriverScreen.LeaveRequests,
leaveRequests = requests,
selectedLeaveRequest = requests.firstOrNull { request -> request.id == it.selectedLeaveRequest?.id } ?: it.selectedLeaveRequest,
error = null,
)
}
_state.update { it.withLoadedLeaveRequests(requests, navigateToList) }
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false, refreshing = false) }
@@ -61,6 +61,18 @@ class DriverUiRulesTest {
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 usesShorterChromeOnCompactScreens() {
assertEquals(118, authBrandBannerHeightDp(600))
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
class DriverUiStateTest {
@@ -20,4 +21,42 @@ class DriverUiStateTest {
assertEquals(AppThemeMode.Material3, state.themeMode)
}
@Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState(
screen = DriverScreen.LeaveRequestDetail,
selectedLeaveRequest = leaveRequest(id = "leave-1", status = "pending"),
)
val refreshed = state.withLoadedLeaveRequests(
requests = listOf(leaveRequest(id = "leave-1", status = "approved")),
navigateToList = false,
)
assertEquals(DriverScreen.LeaveRequestDetail, refreshed.screen)
assertEquals("approved", refreshed.selectedLeaveRequest?.status)
}
@Test
fun openingLeaveRequestsNavigatesToList() {
val state = DriverUiState(screen = DriverScreen.Routes)
val refreshed = state.withLoadedLeaveRequests(
requests = listOf(leaveRequest(id = "leave-1", status = "pending")),
navigateToList = true,
)
assertEquals(DriverScreen.LeaveRequests, refreshed.screen)
assertEquals(1, refreshed.leaveRequests.size)
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
dateFrom = "2026-07-10",
dateTo = "2026-07-12",
type = "URLOP",
status = status,
)
}