diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bd7f728..03d8d21 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 = 23 - versionName = "1.0.22" + versionCode = 26 + versionName = "1.0.25" setProperty("archivesBaseName", "pl.firmatpp.kierowca") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index c92e7a5..5293067 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -46,6 +46,9 @@ class DriverRepository( suspend fun route(routeId: String): RouteResponse = api.route(authHeader(requireToken()), routeId) + suspend fun completeRoute(routeId: String) = + api.completeRoute(authHeader(requireToken()), routeId) + suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse = api.syncState(authHeader(requireToken()), date, routeId) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index 1579c90..5ccecb1 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -5,6 +5,7 @@ import okhttp3.RequestBody import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthBody 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.PhotoUploadResponse import pl.firmatpp.kierowca.data.model.PushTokenBody @@ -46,6 +47,12 @@ interface MobileDriverApi { @Path("routeId") routeId: String, ): RouteResponse + @POST("mobile/driver/routes/{routeId}/complete") + suspend fun completeRoute( + @Header("Authorization") authorization: String, + @Path("routeId") routeId: String, + ): CompleteRouteResponse + @GET("mobile/driver/sync-state") suspend fun syncState( @Header("Authorization") authorization: String, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index 2ae5a67..149d68b 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -57,6 +57,8 @@ data class DriverAppSettingsDto( val maxPastDays: Int, val autoRefreshSeconds: Int, val allowGalleryUploads: Boolean?, + val allowRouteCompletion: Boolean?, + val requirePreciseLocationForPhotos: Boolean?, ) data class DriverSessionDto( @@ -73,6 +75,14 @@ data class RouteResponse( 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( val scopes: List = emptyList(), ) @@ -113,6 +123,8 @@ data class DriverRouteDto( val distanceKm: Double?, val notes: String?, val truck: TruckDto?, + val completedAt: String? = null, + val completedByDriver: DriverDto? = null, val originNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null, val photos: List = emptyList(), diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt index 657fde4..6eb48e7 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt @@ -5,6 +5,7 @@ import com.google.gson.Gson import java.io.IOException import pl.firmatpp.kierowca.data.DriverRepository 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.SyncScopeDto import pl.firmatpp.kierowca.data.model.SyncStateResponse @@ -59,6 +60,32 @@ class DriverSyncRepository( 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 = repository.syncState(date, routeId) 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 1566dd4..d8576c2 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -11,6 +11,7 @@ import android.location.Location import android.location.LocationManager import android.net.Uri import android.os.Build +import android.provider.Settings import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult 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.CameraAlt 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.Factory import androidx.compose.material.icons.outlined.Delete @@ -90,6 +92,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale @@ -100,6 +103,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.foundation.verticalScroll @@ -191,6 +195,7 @@ fun DriverApp(viewModel: DriverViewModel) { viewModel::deleteConfirmedUpload, viewModel::retryPhotoUpload, viewModel::refreshSelectedRoute, + viewModel::completeSelectedRoute, ) DriverScreen.Photo -> PhotoScreen(state, viewModel::back) DriverScreen.PhotoQueue -> PhotoQueueScreen( @@ -213,98 +218,105 @@ private fun StartupScreen() { Column( Modifier .fillMaxSize() - .statusBarsPadding() .navigationBarsPadding() - .padding(32.dp), + .background(TppColors.Surface), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, ) { - Image( - painter = painterResource(R.drawable.tpp5), - contentDescription = "Firma TPP", - contentScale = ContentScale.Fit, - modifier = Modifier + Column(Modifier.fillMaxWidth().statusBarsPadding()) { + BrandBannerHeader(height = 178.dp) + } + Column( + Modifier + .weight(1f) .fillMaxWidth() - .height(120.dp), - ) - Spacer(Modifier.height(32.dp)) - CircularProgressIndicator(color = TppColors.Forest) - Spacer(Modifier.height(20.dp)) - Text( - "Sprawdzanie sesji", - color = TppColors.Muted, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.SemiBold, + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) + { + CircularProgressIndicator(color = TppColors.Forest) + Spacer(Modifier.height(20.dp)) + Text( + "Sprawdzanie sesji", + color = TppColors.Muted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold, + ) + } } } @Composable private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) } - Box( + Column( Modifier .fillMaxSize() .background(TppColors.Surface) - .statusBarsPadding() - .navigationBarsPadding() - .padding(24.dp), + .navigationBarsPadding(), ) { - Card( - modifier = Modifier.fillMaxSize(), - colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)), - border = BorderStroke(1.dp, TppColors.Outline), - shape = RoundedCornerShape(4.dp), + Column(Modifier.fillMaxWidth().statusBarsPadding()) { + BrandBannerHeader(height = 190.dp) + } + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp), + horizontalAlignment = Alignment.Start, ) { - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 28.dp, vertical = 36.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Text( + "Firma TPP - Kierowca", + color = TppColors.Forest, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + 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( - 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)) { + Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) { Text( - "Numer Telefonu", - color = Color.Black, + "Numer telefonu", + color = TppColors.Ink, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, - fontSize = 23.sp, + fontSize = 18.sp, ) PhoneNumberField( digits = phoneDigits, onDigitsChange = { phoneDigits = polishPhoneDigits(it) }, ) Text( - "Otrzymasz kod SMS do weryfikacji.", + "Dostęp tylko dla upoważnionych kierowców.", color = TppColors.Muted, - fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, - fontSize = 18.sp, + fontSize = 15.sp, ) ErrorText(state.error) - Spacer(Modifier.height(if (state.error.isNullOrBlank()) 22.dp else 8.dp)) Button( onClick = { onSubmit("+48$phoneDigits") }, modifier = Modifier .fillMaxWidth() - .height(72.dp), + .height(64.dp), colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen), shape = RoundedCornerShape(4.dp), contentPadding = PaddingValues(horizontal = 20.dp), @@ -313,7 +325,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) { "Wyślij kod OTP", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, - fontSize = 22.sp, + fontSize = 20.sp, ) } } @@ -322,6 +334,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 private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) { Row( @@ -343,10 +395,11 @@ private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) { } Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline)) BasicTextField( - value = formatPolishPhoneDigits(digits), + value = digits, onValueChange = { onDigitsChange(it) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + visualTransformation = PolishPhoneNumberVisualTransformation, textStyle = TextStyle( color = TppColors.Muted, fontSize = 28.sp, @@ -603,27 +656,12 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa @Composable private fun StitchHeader() { - Column(Modifier.fillMaxWidth().background(Color.White).statusBarsPadding()) { - Box( - Modifier.fillMaxWidth().height(104.dp).padding(horizontal = 24.dp), - contentAlignment = Alignment.Center, - ) { - TppLogo() - } - HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.8f), thickness = 2.dp) + Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) { + BrandBannerHeader(height = 124.dp) + HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.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 private fun DateChip(date: String) { val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) } @@ -884,18 +922,37 @@ private fun DetailScreen( onDeleteUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit, onRefresh: () -> Unit, + onCompleteRoute: () -> Unit, ) { val context = LocalContext.current var cameraUri by remember { mutableStateOf(null) } + var showCompleteDialog by remember { mutableStateOf(false) } + var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> 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 } val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val pendingUri = cameraUri 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) } val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> @@ -919,6 +976,17 @@ private fun DetailScreen( ) { item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) } 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 { CargoDocumentationSection( photos = route.photos, @@ -934,7 +1002,10 @@ private fun DetailScreen( onCamera = { val newUri = createCameraUri(context) cameraUri = newUri - val missingPermissions = cameraCapturePermissions(context) + val missingPermissions = cameraCapturePermissions( + context = context, + requirePreciseLocation = state.requirePreciseLocationForPhotos, + ) if (missingPermissions.isEmpty()) { cameraLauncher.launch(newUri) } else { @@ -957,6 +1028,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 @@ -981,6 +1107,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 = when { count == 1 -> "zdjęcie" @@ -1218,6 +1424,7 @@ private fun PhotoGrid( is PhotoGridItem.Upload -> PendingPhotoTile( upload = item.upload, isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true, + onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) }, onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) }, onRetry = onRetryUpload, modifier = Modifier.weight(1f), @@ -1324,6 +1531,7 @@ private fun PhotoTile( private fun PendingPhotoTile( upload: PhotoUploadEntity, isDeleting: Boolean, + onPhoto: (PhotoUploadEntity) -> Unit, onDelete: (PhotoUploadEntity) -> Unit, onRetry: (PhotoUploadEntity) -> Unit, modifier: Modifier = Modifier, @@ -1335,7 +1543,7 @@ private fun PendingPhotoTile( model = File(upload.localPath), contentDescription = "Zdjęcie oczekujące na zapis", contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().clickable { onPhoto(upload) }, ) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f))) if (isDeleting) { @@ -1513,9 +1721,6 @@ private fun polishPhoneDigits(value: String): String { return withoutCountryCode.take(9) } -private fun formatPolishPhoneDigits(value: String): String = - value.chunked(3).joinToString(" ") - private fun createCameraUri(context: Context): Uri { val dir = File(context.cacheDir, "camera").apply { mkdirs() } val file = File.createTempFile("ladunek-", ".jpg", dir) @@ -1542,11 +1747,15 @@ private fun openNavigation(context: Context, point: NavigationPointDto) { } } -private fun cameraCapturePermissions(context: Context): Array = buildList { +private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array = buildList { if (!hasPermission(context, 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_COARSE_LOCATION) } @@ -1570,6 +1779,14 @@ private fun hasLocationPermission(context: Context): Boolean = hasPermission(context, Manifest.permission.ACCESS_FINE_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? { if (!hasLocationPermission(context)) return null 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 01e1209..83cd2dc 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -1,11 +1,23 @@ package pl.firmatpp.kierowca.ui +import java.io.File import java.time.LocalDate 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 = 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 = if (photoCount <= 0) 0 else (photoCount + 1) / 2 @@ -23,6 +35,31 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? = serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() } +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? = value?.takeIf { it.isNotBlank() }?.let { runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 5c1c6b4..2db4205 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -42,15 +42,19 @@ data class DriverUiState( val maxRouteDate: String = LocalDate.now().plusDays(1).toString(), val autoRefreshSeconds: Int = 60, val allowGalleryUploads: Boolean = true, + val allowRouteCompletion: Boolean = false, + val requirePreciseLocationForPhotos: Boolean = false, val selectedRoute: DriverRouteDto? = null, val selectedPhoto: RoutePhotoDto? = null, val photoUploads: List = emptyList(), val queuedPhotoUploads: List = emptyList(), val deletingPhotoIds: Set = emptySet(), + val completingRoute: Boolean = false, val imageAuthHeader: String? = null, val isOnline: Boolean = true, val isStale: Boolean = false, val lastSuccessfulSyncAtEpochMillis: Long? = null, + val feedback: 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) { 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 { val cached = syncRepository.bootstrap(date) @@ -151,6 +155,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) maxRouteDate = settings?.maxDate ?: it.maxRouteDate, autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds, allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads, + allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, + requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos + ?: it.requirePreciseLocationForPhotos, imageAuthHeader = repository.imageAuthHeader(), isStale = cached.stale || !it.isOnline, lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, @@ -190,6 +197,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) it.copy( screen = DriverScreen.Detail, selectedRoute = response.route, + feedback = null, isStale = cached.stale || !it.isOnline, lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, error = null, @@ -202,7 +210,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val routeId = _state.value.selectedRoute?.id ?: return viewModelScope.launch { - _state.update { it.copy(refreshing = true, error = null) } + _state.update { it.copy(refreshing = true, feedback = null, error = null) } runCatching { syncRepository.route(routeId) } .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) { _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.Detail -> { 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.Otp -> it.copy(screen = DriverScreen.Phone) diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/PolishPhoneNumberVisualTransformation.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/PolishPhoneNumberVisualTransformation.kt new file mode 100644 index 0000000..58e2c3c --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/PolishPhoneNumberVisualTransformation.kt @@ -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) + } + }, + ) + } +} diff --git a/app/src/main/res/drawable-nodpi/tpp_banner.png b/app/src/main/res/drawable-nodpi/tpp_banner.png new file mode 100644 index 0000000..d43ef55 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/tpp_banner.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 3ba4e35..650a05b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - - - - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 3ba4e35..650a05b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ - - - - - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 1224ecb..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..513de0c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 21c41e7..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d090642 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..331ec1e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 1224ecb..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..eb20718 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index f7d616b..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4168b85 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index bede23a..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..8118bb9 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..7946865 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index f7d616b..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b4b2979 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index a80fc8b..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..8f37117 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index bff5649..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..6affb12 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..4832afe Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index a80fc8b..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..cd1d721 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 47c9aa2..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7b424ca Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 6d83b20..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4f4c343 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..602123e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 47c9aa2..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..2daead0 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 6b47ca4..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..5cebf43 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index af83f6f..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b741705 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..98c8291 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 6b47ca4..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e1ffdf9 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..b537b51 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #0B192E + \ No newline at end of file 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 cea7d4d..1d4abfc 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity class DriverUiRulesTest { private val today = LocalDate.parse("2026-06-30") @@ -22,6 +24,16 @@ class DriverUiRulesTest { 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 fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { assertEquals(0, inlinePhotoGridRows(0)) @@ -87,4 +99,57 @@ class DriverUiRulesTest { assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42")) assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42")) } + + @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, + ) } diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/PolishPhoneNumberVisualTransformationTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/PolishPhoneNumberVisualTransformationTest.kt new file mode 100644 index 0000000..797e7d0 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/PolishPhoneNumberVisualTransformationTest.kt @@ -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)) + } +}