Update driver app photo handling
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SyncScopeDto> = 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<RoutePhotoDto> = emptyList(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,21 +218,22 @@ private fun StartupScreen() {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(32.dp),
|
||||
.background(TppColors.Surface),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 178.dp)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.tpp5),
|
||||
contentDescription = "Firma TPP",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp),
|
||||
)
|
||||
Spacer(Modifier.height(32.dp))
|
||||
{
|
||||
CircularProgressIndicator(color = TppColors.Forest)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
@@ -238,73 +244,79 @@ private fun StartupScreen() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 190.dp)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 28.dp, vertical = 36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.tpp5),
|
||||
contentDescription = "Firma TPP",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(132.dp),
|
||||
Text(
|
||||
"Firma TPP - Kierowca",
|
||||
color = TppColors.Forest,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Spacer(Modifier.height(42.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"Zaloguj się",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = TppColors.Muted,
|
||||
color = TppColors.Ink,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(76.dp))
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Numer Telefonu",
|
||||
color = Color.Black,
|
||||
"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),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Text(
|
||||
"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,25 +656,10 @@ 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()
|
||||
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 124.dp)
|
||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.dp)
|
||||
}
|
||||
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
|
||||
@@ -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<Uri?>(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<String> = buildList {
|
||||
private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = 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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<PhotoUploadEntity> = emptyList(),
|
||||
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||
val deletingPhotoIds: Set<String> = 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
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.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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||