Compare commits

...
2 Commits
Author SHA1 Message Date
admin fa98451533 Add photo upload retry controls 2026-07-01 22:39:47 +02:00
admin ee9d8cdbaf Update driver app photo handling 2026-07-01 19:26:28 +02:00
50 changed files with 631 additions and 114 deletions
+5 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 23 versionCode = 28
versionName = "1.0.22" versionName = "1.0.27"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -68,6 +68,9 @@ android {
release { release {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false isMinifyEnabled = false
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -46,6 +46,9 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse = suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId) api.route(authHeader(requireToken()), routeId)
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse = suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
api.syncState(authHeader(requireToken()), date, routeId) api.syncState(authHeader(requireToken()), date, routeId)
@@ -5,6 +5,7 @@ import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody import pl.firmatpp.kierowca.data.model.PushTokenBody
@@ -46,6 +47,12 @@ interface MobileDriverApi {
@Path("routeId") routeId: String, @Path("routeId") routeId: String,
): RouteResponse ): RouteResponse
@POST("mobile/driver/routes/{routeId}/complete")
suspend fun completeRoute(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
): CompleteRouteResponse
@GET("mobile/driver/sync-state") @GET("mobile/driver/sync-state")
suspend fun syncState( suspend fun syncState(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -57,6 +57,8 @@ data class DriverAppSettingsDto(
val maxPastDays: Int, val maxPastDays: Int,
val autoRefreshSeconds: Int, val autoRefreshSeconds: Int,
val allowGalleryUploads: Boolean?, val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
) )
data class DriverSessionDto( data class DriverSessionDto(
@@ -73,6 +75,14 @@ data class RouteResponse(
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
) )
data class CompleteRouteResponse(
val ok: Boolean,
val alreadyCompleted: Boolean,
val message: String?,
val completedAt: String?,
val route: DriverRouteDto,
)
data class SyncStateResponse( data class SyncStateResponse(
val scopes: List<SyncScopeDto> = emptyList(), val scopes: List<SyncScopeDto> = emptyList(),
) )
@@ -113,6 +123,8 @@ data class DriverRouteDto(
val distanceKm: Double?, val distanceKm: Double?,
val notes: String?, val notes: String?,
val truck: TruckDto?, val truck: TruckDto?,
val completedAt: String? = null,
val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null, val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null,
val photos: List<RoutePhotoDto> = emptyList(), val photos: List<RoutePhotoDto> = emptyList(),
@@ -5,6 +5,7 @@ import com.google.gson.Gson
import java.io.IOException import java.io.IOException
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncScopeDto import pl.firmatpp.kierowca.data.model.SyncScopeDto
import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.SyncStateResponse
@@ -59,6 +60,32 @@ class DriverSyncRepository(
cachedRoute(routeId, throwable) cachedRoute(routeId, throwable)
} }
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = route.id,
payloadJson = gson.toJson(RouteResponse(route = route)),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse = suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
repository.syncState(date, routeId) repository.syncState(date, routeId)
@@ -11,6 +11,7 @@ import android.location.Location
import android.location.LocationManager 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 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
@@ -51,6 +52,7 @@ import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.ArrowBack import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CalendarToday import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
@@ -59,6 +61,7 @@ import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Navigation import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
@@ -90,6 +93,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -100,6 +104,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -191,6 +196,7 @@ fun DriverApp(viewModel: DriverViewModel) {
viewModel::deleteConfirmedUpload, viewModel::deleteConfirmedUpload,
viewModel::retryPhotoUpload, viewModel::retryPhotoUpload,
viewModel::refreshSelectedRoute, viewModel::refreshSelectedRoute,
viewModel::completeSelectedRoute,
) )
DriverScreen.Photo -> PhotoScreen(state, viewModel::back) DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
DriverScreen.PhotoQueue -> PhotoQueueScreen( DriverScreen.PhotoQueue -> PhotoQueueScreen(
@@ -213,98 +219,105 @@ private fun StartupScreen() {
Column( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding() .navigationBarsPadding()
.padding(32.dp), .background(TppColors.Surface),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { ) {
Image( Column(Modifier.fillMaxWidth().statusBarsPadding()) {
painter = painterResource(R.drawable.tpp5), BrandBannerHeader(height = 178.dp)
contentDescription = "Firma TPP", }
contentScale = ContentScale.Fit, Column(
modifier = Modifier Modifier
.weight(1f)
.fillMaxWidth() .fillMaxWidth()
.height(120.dp), .padding(horizontal = 32.dp),
) horizontalAlignment = Alignment.CenterHorizontally,
Spacer(Modifier.height(32.dp)) verticalArrangement = Arrangement.Center,
CircularProgressIndicator(color = TppColors.Forest)
Spacer(Modifier.height(20.dp))
Text(
"Sprawdzanie sesji",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
) )
{
CircularProgressIndicator(color = TppColors.Forest)
Spacer(Modifier.height(20.dp))
Text(
"Sprawdzanie sesji",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
)
}
} }
} }
@Composable @Composable
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) } var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
Box( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(TppColors.Surface) .background(TppColors.Surface)
.statusBarsPadding() .navigationBarsPadding(),
.navigationBarsPadding()
.padding(24.dp),
) { ) {
Card( Column(Modifier.fillMaxWidth().statusBarsPadding()) {
modifier = Modifier.fillMaxSize(), BrandBannerHeader(height = 190.dp)
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)), }
border = BorderStroke(1.dp, TppColors.Outline), Column(
shape = RoundedCornerShape(4.dp), Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp),
horizontalAlignment = Alignment.Start,
) { ) {
Column( Text(
Modifier "Firma TPP - Kierowca",
.fillMaxSize() color = TppColors.Forest,
.verticalScroll(rememberScrollState()) fontFamily = FontFamily.Monospace,
.padding(horizontal = 28.dp, vertical = 36.dp), fontWeight = FontWeight.Bold,
horizontalAlignment = Alignment.CenterHorizontally, fontSize = 14.sp,
)
Spacer(Modifier.height(10.dp))
Text(
"Zaloguj się",
style = MaterialTheme.typography.headlineLarge,
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Text(
"Wpisz numer telefonu kierowcy. Kod OTP wyślemy SMS-em.",
color = TppColors.Muted,
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(28.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
shape = RoundedCornerShape(8.dp),
) { ) {
Image( Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
painter = painterResource(R.drawable.tpp5),
contentDescription = "Firma TPP",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(132.dp),
)
Spacer(Modifier.height(42.dp))
Text(
"Zaloguj się",
style = MaterialTheme.typography.headlineLarge,
color = TppColors.Muted,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(76.dp))
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text( Text(
"Numer Telefonu", "Numer telefonu",
color = Color.Black, color = TppColors.Ink,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 23.sp, fontSize = 18.sp,
) )
PhoneNumberField( PhoneNumberField(
digits = phoneDigits, digits = phoneDigits,
onDigitsChange = { phoneDigits = polishPhoneDigits(it) }, onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
) )
Text( Text(
"Otrzymasz kod SMS do weryfikacji.", "Dostęp tylko dla upoważnionych kierowców.",
color = TppColors.Muted, color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 18.sp, fontSize = 15.sp,
) )
ErrorText(state.error) ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 22.dp else 8.dp))
Button( Button(
onClick = { onSubmit("+48$phoneDigits") }, onClick = { onSubmit("+48$phoneDigits") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(72.dp), .height(64.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen), colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 20.dp), contentPadding = PaddingValues(horizontal = 20.dp),
@@ -313,7 +326,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
"Wyślij kod OTP", "Wyślij kod OTP",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 22.sp, fontSize = 20.sp,
) )
} }
} }
@@ -322,6 +335,46 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
} }
} }
@Composable
private fun BrandBannerHeader(height: Dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.background(Color(0xFFFAFCF1)),
) {
Image(
painter = painterResource(R.drawable.tpp_banner),
contentDescription = "Firma TPP",
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize(),
)
Box(
Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
0f to Color(0xFFFAFCF1).copy(alpha = 0.56f),
0.18f to Color.Transparent,
0.82f to Color.Transparent,
1f to Color(0xFFFAFCF1).copy(alpha = 0.56f),
),
),
)
Box(
Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
0.72f to Color.Transparent,
1f to TppColors.Surface.copy(alpha = 0.88f),
),
),
)
}
}
@Composable @Composable
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) { private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
Row( Row(
@@ -343,10 +396,11 @@ private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
} }
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline)) Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
BasicTextField( BasicTextField(
value = formatPolishPhoneDigits(digits), value = digits,
onValueChange = { onDigitsChange(it) }, onValueChange = { onDigitsChange(it) },
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
visualTransformation = PolishPhoneNumberVisualTransformation,
textStyle = TextStyle( textStyle = TextStyle(
color = TppColors.Muted, color = TppColors.Muted,
fontSize = 28.sp, fontSize = 28.sp,
@@ -592,9 +646,21 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
) )
} }
} }
if (status == PhotoUploadStatus.FailedRetryable) { if (canRetryPhotoUpload(upload.status)) {
TextButton(onClick = { onRetryUpload(upload) }) { Button(
Text("Ponów", color = TppColors.Forest, fontWeight = FontWeight.Bold) 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)
} }
} }
} }
@@ -603,27 +669,12 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
@Composable @Composable
private fun StitchHeader() { private fun StitchHeader() {
Column(Modifier.fillMaxWidth().background(Color.White).statusBarsPadding()) { Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
Box( BrandBannerHeader(height = 124.dp)
Modifier.fillMaxWidth().height(104.dp).padding(horizontal = 24.dp), HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.dp)
contentAlignment = Alignment.Center,
) {
TppLogo()
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.8f), thickness = 2.dp)
} }
} }
@Composable
private fun TppLogo() {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "TPP",
contentScale = ContentScale.Fit,
modifier = Modifier.width(142.dp).height(50.dp),
)
}
@Composable @Composable
private fun DateChip(date: String) { private fun DateChip(date: String) {
val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) } val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) }
@@ -884,18 +935,37 @@ private fun DetailScreen(
onDeleteUpload: (PhotoUploadEntity) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onCompleteRoute: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showCompleteDialog by remember { mutableStateOf(false) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri val capturedUri = cameraUri
if (ok && capturedUri != null) onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) if (
ok &&
capturedUri != null &&
canLaunchCameraWithLocationPolicy(
requirePreciseLocation = state.requirePreciseLocationForPhotos,
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
)
) {
onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
}
cameraUri = null cameraUri = null
} }
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val pendingUri = cameraUri val pendingUri = cameraUri
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA) val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
showPreciseLocationPermissionDialog = true
cameraUri = null
return@rememberLauncherForActivityResult
}
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri) if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri)
} }
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
@@ -919,6 +989,17 @@ private fun DetailScreen(
) { ) {
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) } item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
item { OfflineStaleBanner(state) } item { OfflineStaleBanner(state) }
item {
RouteCompletionSection(
route = route,
selectedDate = state.selectedDate,
allowRouteCompletion = state.allowRouteCompletion,
isOnline = state.isOnline,
completing = state.completingRoute,
feedback = state.feedback,
onComplete = { showCompleteDialog = true },
)
}
item { item {
CargoDocumentationSection( CargoDocumentationSection(
photos = route.photos, photos = route.photos,
@@ -934,7 +1015,10 @@ private fun DetailScreen(
onCamera = { onCamera = {
val newUri = createCameraUri(context) val newUri = createCameraUri(context)
cameraUri = newUri cameraUri = newUri
val missingPermissions = cameraCapturePermissions(context) val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) { if (missingPermissions.isEmpty()) {
cameraLauncher.launch(newUri) cameraLauncher.launch(newUri)
} else { } else {
@@ -957,6 +1041,61 @@ private fun DetailScreen(
) )
} }
} }
if (showCompleteDialog && route != null) {
AlertDialog(
onDismissRequest = { if (!state.completingRoute) showCompleteDialog = false },
title = { Text("Zakończyć kurs?", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = { Text("Status kursu zostanie ustawiony jako zakończony.", color = TppColors.Muted) },
confirmButton = {
TextButton(
enabled = !state.completingRoute,
onClick = {
showCompleteDialog = false
onCompleteRoute()
},
) {
Text("Zakończ kurs", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(
enabled = !state.completingRoute,
onClick = { showCompleteDialog = false },
) {
Text("Anuluj", color = TppColors.Muted)
}
},
)
}
if (showPreciseLocationPermissionDialog) {
AlertDialog(
onDismissRequest = { showPreciseLocationPermissionDialog = false },
title = { Text("Brak dokładnej lokalizacji", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = {
Text(
"Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.",
color = TppColors.Muted,
)
},
confirmButton = {
TextButton(
onClick = {
showPreciseLocationPermissionDialog = false
openAppSettings(context)
},
) {
Text("Przejdź do ustawień", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showPreciseLocationPermissionDialog = false }) {
Text("Anuluj", color = TppColors.Muted)
}
},
)
}
} }
@Composable @Composable
@@ -981,6 +1120,86 @@ private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy"
} }
} }
@Composable
private fun RouteCompletionSection(
route: DriverRouteDto,
selectedDate: String,
allowRouteCompletion: Boolean,
isOnline: Boolean,
completing: Boolean,
feedback: String?,
onComplete: () -> Unit,
) {
if (!allowRouteCompletion) return
if (route.status == "ZAKOŃCZONA") {
if (!feedback.isNullOrBlank()) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFEAF7EF)),
border = BorderStroke(1.dp, Color(0xFF9BD1AD)),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = TppColors.Forest)
Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
}
}
return
}
if (!canCompleteRouteFromDriverApp(route, selectedDate)) return
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, TppColors.Outline),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Button(
onClick = onComplete,
enabled = isOnline && !completing,
modifier = Modifier.fillMaxWidth().height(58.dp),
colors = ButtonDefaults.buttonColors(
containerColor = TppColors.Forest,
disabledContainerColor = TppColors.Outline,
),
shape = RoundedCornerShape(8.dp),
) {
if (completing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White)
}
Spacer(Modifier.width(10.dp))
Text(
if (completing) "Kończę kurs..." else "Zakończ kurs",
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
if (!isOnline) {
Text(
"Kurs można zakończyć tylko po połączeniu z serwerem.",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
private fun photoCountLabel(count: Int): String = private fun photoCountLabel(count: Int): String =
when { when {
count == 1 -> "zdjęcie" count == 1 -> "zdjęcie"
@@ -1218,6 +1437,7 @@ private fun PhotoGrid(
is PhotoGridItem.Upload -> PendingPhotoTile( is PhotoGridItem.Upload -> PendingPhotoTile(
upload = item.upload, upload = item.upload,
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true, isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) }, onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
onRetry = onRetryUpload, onRetry = onRetryUpload,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -1324,6 +1544,7 @@ private fun PhotoTile(
private fun PendingPhotoTile( private fun PendingPhotoTile(
upload: PhotoUploadEntity, upload: PhotoUploadEntity,
isDeleting: Boolean, isDeleting: Boolean,
onPhoto: (PhotoUploadEntity) -> Unit,
onDelete: (PhotoUploadEntity) -> Unit, onDelete: (PhotoUploadEntity) -> Unit,
onRetry: (PhotoUploadEntity) -> Unit, onRetry: (PhotoUploadEntity) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -1335,7 +1556,7 @@ private fun PendingPhotoTile(
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,
modifier = Modifier.fillMaxSize(), 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)))
if (isDeleting) { if (isDeleting) {
@@ -1365,6 +1586,25 @@ private fun PendingPhotoTile(
) )
} }
} }
if (canRetryPhotoUpload(upload.status)) {
IconButton(
onClick = { onRetry(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.Forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
) {
Icon(
Icons.Outlined.Refresh,
contentDescription = "Ponów wysłanie zdjęcia",
tint = TppColors.Forest,
modifier = Modifier.size(20.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),
@@ -1384,16 +1624,6 @@ private fun PendingPhotoTile(
trackColor = TppColors.Outline.copy(alpha = 0.4f), trackColor = TppColors.Outline.copy(alpha = 0.4f),
) )
} }
if (status == PhotoUploadStatus.FailedRetryable) {
Button(
onClick = { onRetry(upload) },
modifier = Modifier.fillMaxWidth().height(40.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(4.dp),
) {
Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold)
}
}
} }
} }
} }
@@ -1513,9 +1743,6 @@ private fun polishPhoneDigits(value: String): String {
return withoutCountryCode.take(9) return withoutCountryCode.take(9)
} }
private fun formatPolishPhoneDigits(value: String): String =
value.chunked(3).joinToString(" ")
private fun createCameraUri(context: Context): Uri { private fun createCameraUri(context: Context): Uri {
val dir = File(context.cacheDir, "camera").apply { mkdirs() } val dir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("ladunek-", ".jpg", dir) val file = File.createTempFile("ladunek-", ".jpg", dir)
@@ -1542,11 +1769,15 @@ private fun openNavigation(context: Context, point: NavigationPointDto) {
} }
} }
private fun cameraCapturePermissions(context: Context): Array<String> = buildList { private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList {
if (!hasPermission(context, Manifest.permission.CAMERA)) { if (!hasPermission(context, Manifest.permission.CAMERA)) {
add(Manifest.permission.CAMERA) add(Manifest.permission.CAMERA)
} }
if (!hasLocationPermission(context)) { if (requirePreciseLocation) {
if (!hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
} else if (!hasLocationPermission(context)) {
add(Manifest.permission.ACCESS_FINE_LOCATION) add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION) add(Manifest.permission.ACCESS_COARSE_LOCATION)
} }
@@ -1570,6 +1801,14 @@ private fun hasLocationPermission(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private fun openAppSettings(context: Context) {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}
private fun bestLastKnownLocation(context: Context): Location? { private fun bestLastKnownLocation(context: Context): Location? {
if (!hasLocationPermission(context)) return null if (!hasLocationPermission(context)) return null
@@ -1,11 +1,23 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.io.File
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false) runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
fun canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
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
@@ -23,6 +35,34 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? = fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() } serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean =
status == "FAILED_RETRYABLE"
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
if (upload.localPath.isBlank()) return null
return RoutePhotoDto(
id = upload.clientRequestId,
routeId = upload.routeId,
clientRequestId = upload.clientRequestId,
contentSha256 = upload.contentSha256,
source = upload.source,
mimeType = upload.mimeType,
size = upload.size,
url = File(upload.localPath).toURI().toString(),
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
createdAt = null,
canDelete = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null,
deleteUntil = null,
)
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation
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()
@@ -42,15 +42,19 @@ data class DriverUiState(
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(), val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
val autoRefreshSeconds: Int = 60, val autoRefreshSeconds: Int = 60,
val allowGalleryUploads: Boolean = true, val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
val selectedRoute: DriverRouteDto? = null, val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val deletingPhotoIds: Set<String> = emptySet(), val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false,
val imageAuthHeader: String? = null, val imageAuthHeader: String? = null,
val isOnline: Boolean = true, val isOnline: Boolean = true,
val isStale: Boolean = false, val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null, val lastSuccessfulSyncAtEpochMillis: Long? = null,
val feedback: String? = null,
val error: String? = null, val error: String? = null,
) )
@@ -135,7 +139,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) } _state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching { runCatching {
val cached = syncRepository.bootstrap(date) val cached = syncRepository.bootstrap(date)
@@ -151,6 +155,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
maxRouteDate = settings?.maxDate ?: it.maxRouteDate, maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds, autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads, allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
imageAuthHeader = repository.imageAuthHeader(), imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
@@ -190,6 +197,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy( it.copy(
screen = DriverScreen.Detail, screen = DriverScreen.Detail,
selectedRoute = response.route, selectedRoute = response.route,
feedback = null,
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null, error = null,
@@ -202,7 +210,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val routeId = _state.value.selectedRoute?.id ?: return val routeId = _state.value.selectedRoute?.id ?: return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null) } _state.update { it.copy(refreshing = true, feedback = null, error = null) }
runCatching { syncRepository.route(routeId) } runCatching { syncRepository.route(routeId) }
.onSuccess { cached -> .onSuccess { cached ->
@@ -301,6 +309,37 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return
if (_state.value.completingRoute || !_state.value.isOnline) return
viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
runCatching {
val completed = repository.completeRoute(route.id).route
syncRepository.cacheConfirmedRoute(completed, _state.value.selectedDate)
val refreshed = runCatching { syncRepository.route(route.id) }
.getOrNull()
?.takeUnless { it.stale }
?.value
?.route
?: completed
photoUploadOutbox.discardConfirmedServerPhotos(refreshed)
_state.update {
it.copy(
selectedRoute = refreshed,
routes = it.routes.map { item -> if (item.id == route.id) refreshed else item },
feedback = "Kurs zakończony",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(completingRoute = false) }
}
}
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) }
} }
@@ -312,7 +351,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Detail -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList()) it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
} }
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes) DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone) DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
object PolishPhoneNumberVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val rawDigits = text.text.filter(Char::isDigit).take(9)
val formatted = rawDigits.chunked(3).joinToString(" ")
return TransformedText(
AnnotatedString(formatted),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val safeOffset = offset.coerceIn(0, rawDigits.length)
val spacesBeforeOffset = when {
safeOffset <= 3 -> 0
safeOffset <= 6 -> 1
else -> 2
}
return (safeOffset + spacesBeforeOffset).coerceAtMost(formatted.length)
}
override fun transformedToOriginal(offset: Int): Int {
val safeOffset = offset.coerceIn(0, formatted.length)
return formatted
.take(safeOffset)
.count(Char::isDigit)
.coerceAtMost(rawDigits.length)
}
},
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0B192E</color>
</resources>
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest { class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30") private val today = LocalDate.parse("2026-06-30")
@@ -22,6 +24,16 @@ class DriverUiRulesTest {
assertFalse(canManageRoutePhotos("not-a-date", today)) assertFalse(canManageRoutePhotos("not-a-date", today))
} }
@Test
fun allowsRouteCompletionOnlyForTodayPlannedOrInProgressRoutes() {
assertTrue(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-30", today))
assertTrue(canCompleteRouteFromDriverApp(route(status = "W TRAKCIE"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAKOŃCZONA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "NIEROZPOZNANA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-07-01", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test @Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0)) assertEquals(0, inlinePhotoGridRows(0))
@@ -87,4 +99,68 @@ class DriverUiRulesTest {
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42")) assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42")) assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
} }
@Test
fun allowsRetryActionOnlyForRetryableFailedUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING"))
assertFalse(canRetryPhotoUpload("CONFIRMED"))
assertFalse(canRetryPhotoUpload("FAILED_PERMANENT"))
assertFalse(canRetryPhotoUpload("CANCELLED"))
}
@Test
fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() {
val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))
requireNotNull(photo)
assertEquals("local-upload-123", photo.id)
assertEquals("10", photo.routeId)
assertEquals("file:/tmp/photo.jpg", photo.url)
assertEquals("2026-07-01T08:00:00Z", photo.takenAt)
assertEquals(50.1, photo.latitude ?: 0.0, 0.0)
assertEquals(true, photo.canDelete)
}
@Test
fun preciseLocationRequirementAllowsCameraOnlyWithFineLocation() {
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = false, hasFineLocation = false))
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = true))
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
}
private fun route(status: String): DriverRouteDto =
DriverRouteDto(
id = "1",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
distanceKm = 1.0,
notes = null,
truck = null,
)
private fun upload(status: String, serverPhotoId: String? = null): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = "local-upload-123",
routeId = "10",
localPath = "/tmp/photo.jpg",
source = "camera",
takenAt = "2026-07-01T08:00:00Z",
latitude = 50.1,
longitude = 19.9,
locationAccuracyMeters = 8.0,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
status = status,
serverPhotoId = serverPhotoId,
)
} }
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Test
class PolishPhoneNumberVisualTransformationTest {
@Test
fun formatsPhoneDigitsWithoutChangingUnderlyingTextOffsets() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("1234"))
assertEquals("123 4", transformed.text.text)
assertEquals(5, transformed.offsetMapping.originalToTransformed(4))
assertEquals(4, transformed.offsetMapping.transformedToOriginal(5))
}
@Test
fun mapsOffsetsAcrossBothInsertedSpaces() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("123456789"))
assertEquals("123 456 789", transformed.text.text)
assertEquals(7, transformed.offsetMapping.originalToTransformed(6))
assertEquals(11, transformed.offsetMapping.originalToTransformed(9))
assertEquals(6, transformed.offsetMapping.transformedToOriginal(7))
assertEquals(9, transformed.offsetMapping.transformedToOriginal(11))
}
}