Compare commits

..
6 Commits
18 changed files with 935 additions and 29 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 33
versionName = "1.0.32"
versionCode = 38
versionName = "1.0.37"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -14,6 +14,7 @@ import pl.firmatpp.kierowca.data.api.MobileDriverApi
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.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -25,6 +26,7 @@ import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverRepository(
@@ -131,6 +133,32 @@ class DriverRepository(
)
}
suspend fun uploadQueuedDispatchSheetPhoto(upload: DispatchSheetUploadEntity): DispatchSheetUploadResponse {
val file = File(upload.localPath)
val body = file.readBytes().toRequestBody(upload.mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", file.name, body)
val metadataParts = PhotoUploadMetadata(
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
).toMultipartTextParts()
return api.uploadDispatchSheetPhoto(
authHeader(requireToken()),
upload.clientRequestId,
photo,
upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
metadataParts["locationAccuracyMeters"],
upload.replacePhotoId?.toPlainTextBody(),
)
}
suspend fun deletePhoto(photoId: String) {
api.deletePhoto(authHeader(requireToken()), photoId)
}
@@ -6,6 +6,7 @@ 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.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -113,6 +114,22 @@ interface MobileDriverApi {
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
): PhotoUploadResponse
@Multipart
@POST("mobile/driver/dispatch-sheet-photos")
suspend fun uploadDispatchSheetPhoto(
@Header("Authorization") authorization: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Part photo: MultipartBody.Part,
@Part("clientRequestId") clientRequestId: RequestBody,
@Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody,
@Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
@Part("replacePhotoId") replacePhotoId: RequestBody?,
): DispatchSheetUploadResponse
@DELETE("mobile/driver/photos/{photoId}")
suspend fun deletePhoto(
@Header("Authorization") authorization: String,
@@ -46,7 +46,9 @@ data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
)
@@ -60,6 +62,36 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
)
data class DispatchSheetReminderDto(
val enabled: Boolean = false,
val dueToday: Boolean = false,
val workDate: String? = null,
val reason: String? = null,
val availableUntil: String? = null,
val status: String = "not_required",
val photo: DispatchSheetPhotoDto? = null,
val canUpload: Boolean = false,
)
data class DispatchSheetPhotoDto(
val id: String,
val driverId: String,
val workDate: String?,
val clientRequestId: String? = null,
val contentSha256: String? = null,
val source: String,
val mimeType: String?,
val size: Long,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val createdAt: String?,
)
data class DriverSessionDto(
@@ -71,6 +103,12 @@ data class NotificationPreferencesDto(
val notifyNewRoutes: Boolean = false,
)
data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
)
data class RoutesBucketDto(
val today: List<DriverRouteDto> = emptyList(),
)
@@ -189,3 +227,16 @@ data class PhotoUploadReceiptDto(
val contentSha256: String,
val storedAt: String?,
)
data class DispatchSheetUploadResponse(
val photo: DispatchSheetPhotoDto,
val reminder: DispatchSheetReminderDto,
val receipt: DispatchSheetUploadReceiptDto,
)
data class DispatchSheetUploadReceiptDto(
val clientRequestId: String,
val serverPhotoId: String,
val contentSha256: String,
val storedAt: String?,
)
@@ -150,5 +150,6 @@ class DriverSyncRepository(
const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
}
}
@@ -0,0 +1,66 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface DispatchSheetUploadDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(upload: DispatchSheetUploadEntity)
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity?
@Query(
"""
SELECT * FROM dispatch_sheet_uploads
WHERE workDate = :workDate
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>>
@Query(
"""
UPDATE dispatch_sheet_uploads
SET status = :status,
progress = :progress,
lastError = :lastError,
attemptCount = attemptCount + :attemptIncrement,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun updateStatus(
clientRequestId: String,
status: String,
progress: Int,
lastError: String?,
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
@Query(
"""
UPDATE dispatch_sheet_uploads
SET status = 'CONFIRMED',
progress = 100,
lastError = NULL,
serverPhotoId = :serverPhotoId,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun markConfirmed(
clientRequestId: String,
serverPhotoId: String,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
}
@@ -0,0 +1,37 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "dispatch_sheet_uploads",
indices = [
Index(value = ["workDate"]),
Index(value = ["status"]),
],
)
data class DispatchSheetUploadEntity(
@PrimaryKey val clientRequestId: String,
val workDate: String,
val replacePhotoId: String?,
val localPath: String,
val source: String,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val mimeType: String,
val size: Long,
val contentSha256: String,
val status: String = PhotoUploadStatus.Pending.storageValue,
val progress: Int = 0,
val attemptCount: Int = 0,
val lastError: String? = null,
val serverPhotoId: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
) {
val statusType: PhotoUploadStatus
get() = PhotoUploadStatus.fromStorage(status)
}
@@ -0,0 +1,102 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import android.net.Uri
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.Flow
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
class DispatchSheetUploadOutbox(
private val context: Context,
private val dao: DispatchSheetUploadDao = DriverDatabase.get(context).dispatchSheetUploadDao(),
private val workManager: WorkManager = WorkManager.getInstance(context),
) {
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>> =
dao.observeVisibleForWorkDate(workDate)
suspend fun enqueue(
workDate: String,
replacePhotoId: String?,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
): DispatchSheetUploadEntity {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
"image/png" -> "png"
"image/webp" -> "webp"
else -> "jpg"
}
val uploadDir = File(context.filesDir, "dispatch-sheet-upload-outbox").apply { mkdirs() }
val localFile = File(uploadDir, "$clientRequestId.$extension")
val sha256 = copyAndHash(uri, localFile)
val upload = DispatchSheetUploadEntity(
clientRequestId = clientRequestId,
workDate = workDate,
replacePhotoId = replacePhotoId,
localPath = localFile.absolutePath,
source = source,
takenAt = metadata.takenAt,
latitude = metadata.latitude,
longitude = metadata.longitude,
locationAccuracyMeters = metadata.locationAccuracyMeters,
mimeType = mimeType,
size = localFile.length(),
contentSha256 = sha256,
)
dao.upsert(upload)
enqueueWorker(clientRequestId)
return upload
}
suspend fun discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
DispatchSheetUploadWorker.uniqueWorkName(clientRequestId),
ExistingWorkPolicy.REPLACE,
request,
)
}
private fun copyAndHash(uri: Uri, target: File): String {
val digest = MessageDigest.getInstance("SHA-256")
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Nie można odczytać zdjęcia karty spedycyjnej." }
target.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read == -1) break
digest.update(buffer, 0, read)
output.write(buffer, 0, read)
}
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,10 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadReceiptDto
object DispatchSheetUploadReceiptVerifier {
fun matches(upload: DispatchSheetUploadEntity, receipt: DispatchSheetUploadReceiptDto): Boolean =
upload.clientRequestId == receipt.clientRequestId &&
upload.contentSha256.equals(receipt.contentSha256, ignoreCase = true) &&
receipt.serverPhotoId.isNotBlank()
}
@@ -0,0 +1,86 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
class DispatchSheetUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).dispatchSheetUploadDao()
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
if (!file.exists()) {
val message = "Lokalny plik zdjęcia karty spedycyjnej nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = PhotoUploadFailureDetails.local(message),
attemptIncrement = 0,
)
return Result.failure()
}
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Uploading.storageValue,
progress = 10,
lastError = null,
attemptIncrement = 1,
)
setProgress(workDataOf(KEY_PROGRESS to 10))
return runCatching {
setProgress(workDataOf(KEY_PROGRESS to 70))
val response = repository.uploadQueuedDispatchSheetPhoto(upload)
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Verifying.storageValue,
progress = 90,
lastError = null,
attemptIncrement = 0,
)
setProgress(workDataOf(KEY_PROGRESS to 90))
val receipt = response.receipt
if (!DispatchSheetUploadReceiptVerifier.matches(upload, receipt)) {
error("Serwer nie potwierdził zgodności zdjęcia karty spedycyjnej. Zdjęcie nie zostało zapisane.")
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = PhotoUploadFailureDetails.fromApiError(error),
attemptIncrement = 0,
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
fun uniqueWorkName(clientRequestId: String): String = "dispatch-sheet-upload-$clientRequestId"
}
}
@@ -14,15 +14,17 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
@Database(
entities = [
PhotoUploadEntity::class,
DispatchSheetUploadEntity::class,
DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 2,
version = 3,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao
abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao
abstract fun driverCacheDao(): DriverCacheDao
companion object {
@@ -71,13 +73,45 @@ abstract class DriverDatabase : RoomDatabase() {
}
}
private val migration2To3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS dispatch_sheet_uploads (
clientRequestId TEXT NOT NULL PRIMARY KEY,
workDate TEXT NOT NULL,
replacePhotoId TEXT,
localPath TEXT NOT NULL,
source TEXT NOT NULL,
takenAt TEXT,
latitude REAL,
longitude REAL,
locationAccuracyMeters REAL,
mimeType TEXT NOT NULL,
size INTEGER NOT NULL,
contentSha256 TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
serverPhotoId TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_workDate ON dispatch_sheet_uploads(workDate)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_status ON dispatch_sheet_uploads(status)")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).addMigrations(migration1To2).build().also { instance = it }
).addMigrations(migration1To2, migration2To3).build().also { instance = it }
}
}
}
@@ -15,8 +15,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
class DriverLiveSyncClient(
private val repository: DriverRepository,
@@ -25,20 +25,29 @@ class DriverLiveSyncClient(
private val client: OkHttpClient = OkHttpClient(),
private val gson: Gson = Gson(),
) {
private companion object {
const val HEARTBEAT_INTERVAL_MS = 10_000L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val started = AtomicBoolean(false)
private var webSocket: WebSocket? = null
private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null
private var heartbeatJob: Job? = null
fun start(driverId: String) {
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
fun start(driverId: String, config: RealtimeConfigDto?) {
val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
if (!config.reverbEnabled) return
realtimeConfig = config
this.driverId = driverId
if (!started.compareAndSet(false, true)) return
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') +
"/" + BuildConfig.REVERB_APP_KEY +
val wsUrl = wsBaseUrl.trimEnd('/') +
"/" + appKey +
"?protocol=7&client=android&version=1.0&flash=false"
webSocket = client.newWebSocket(
@@ -72,6 +81,7 @@ class DriverLiveSyncClient(
webSocket?.close(1000, "logout")
webSocket = null
driverId = null
realtimeConfig = null
socketId = null
}
@@ -146,7 +156,7 @@ class DriverLiveSyncClient(
scope.launch {
delay(5_000)
if (!started.get() && driverId == id) {
start(id)
start(id, realtimeConfig)
}
}
}
@@ -156,7 +166,7 @@ class DriverLiveSyncClient(
heartbeatJob = scope.launch {
while (started.get()) {
reportRealtimeStatus("connected")
delay(30_000)
delay(HEARTBEAT_INTERVAL_MS)
}
}
}
@@ -42,6 +42,11 @@ class DriverSyncWorker(
refreshed = true
}
}
DriverSyncRepository.SCOPE_SETTINGS,
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> {
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
}
}
}
@@ -132,9 +132,11 @@ import kotlinx.coroutines.delay
import java.util.Locale
import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
import pl.firmatpp.kierowca.domain.OtpCodeExtractor
@@ -202,7 +204,9 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
onDate = viewModel::selectRouteDate,
onProfile = viewModel::openProfile,
onPhotoQueue = viewModel::openPhotoQueue,
onDispatchSheetUpload = viewModel::uploadDispatchSheetPhoto,
onRoute = viewModel::openRoute,
onDismissLiveUpdate = viewModel::dismissRouteDayLiveUpdate,
)
DriverScreen.Profile -> ProfileScreen(
state = state,
@@ -545,8 +549,47 @@ private fun RoutesScreen(
onDate: (String) -> Unit,
onProfile: () -> Unit,
onPhotoQueue: () -> Unit,
onDispatchSheetUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onRoute: (String) -> Unit,
onDismissLiveUpdate: () -> Unit,
) {
val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri
if (
ok &&
capturedUri != null &&
canLaunchCameraWithLocationPolicy(
requirePreciseLocation = state.requirePreciseLocationForPhotos,
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
)
) {
onDispatchSheetUpload(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)
}
LaunchedEffect(state.routeDayLiveUpdateMessage) {
if (!state.routeDayLiveUpdateMessage.isNullOrBlank()) {
delay(6_000)
onDismissLiveUpdate()
}
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val screenWidthDp = maxWidth.value.toInt()
val screenHeightDp = maxHeight.value.toInt()
@@ -554,7 +597,17 @@ private fun RoutesScreen(
val compactWidth = screenWidthDp < 360
Scaffold(
topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) },
topBar = {
Box(Modifier.fillMaxWidth()) {
StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp)
RouteDayLiveUpdateBanner(
message = state.routeDayLiveUpdateMessage,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = horizontalPadding, vertical = 10.dp),
)
}
},
bottomBar = {
StitchBottomBar(
activeScreen = DriverScreen.Routes,
@@ -607,6 +660,27 @@ private fun RoutesScreen(
onDate = onDate,
)
}
if (shouldShowDispatchSheetReminderCard(state.dispatchSheetReminder)) {
item {
DispatchSheetReminderCard(
reminder = state.dispatchSheetReminder,
uploads = state.dispatchSheetUploads,
onCamera = {
val newUri = createCameraUri(context)
cameraUri = newUri
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(missingPermissions)
}
},
)
}
}
item { OfflineStaleBanner(state) }
item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
if (state.routes.isEmpty()) {
@@ -626,6 +700,143 @@ private fun RoutesScreen(
}
}
}
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
private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modifier) {
if (message.isNullOrBlank()) return
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),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(40.dp).background(Color.White, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Refresh, contentDescription = null, tint = TppColors.Forest)
}
Text(
message,
color = TppColors.Forest,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun DispatchSheetReminderCard(
reminder: DispatchSheetReminderDto?,
uploads: List<DispatchSheetUploadEntity>,
onCamera: () -> Unit,
) {
if (!shouldShowDispatchSheetReminderCard(reminder)) return
val queuedCount = queuedPhotoUploadCount(uploads.map { it.status })
val hasQueuedUpload = queuedCount > 0
val uploaded = reminder?.status == "uploaded" && !hasQueuedUpload
val container = if (uploaded) Color(0xFFEAF7EF) else Color.White
val border = if (uploaded) Color(0xFF9BD1AD) else TppColors.Outline
val actionLabel = dispatchSheetPrimaryActionLabel(reminder, hasQueuedUpload)
Card(
colors = CardDefaults.cardColors(containerColor = container),
border = BorderStroke(1.dp, border),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
if (uploaded) Icons.Outlined.CheckCircle else Icons.Outlined.CameraAlt,
contentDescription = null,
tint = if (uploaded) TppColors.Forest else TppColors.Ink,
modifier = Modifier.size(28.dp),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
if (uploaded) "Zdjęcie karty spedycyjnej wykonane" else "Karta spedycyjna",
color = TppColors.Ink,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
if (uploaded) {
"Dokument został zapisany. W razie potrzeby możesz zrobić poprawione zdjęcie."
} else {
"Po zakończeniu dnia zrób zdjęcie karty spedycyjnej."
},
color = TppColors.Muted,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Button(
onClick = onCamera,
enabled = reminder?.canUpload == true && !hasQueuedUpload,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (uploaded) TppColors.ContainerGreen else TppColors.Forest,
disabledContainerColor = TppColors.Outline,
),
shape = RoundedCornerShape(8.dp),
) {
if (hasQueuedUpload) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Outlined.CameraAlt, contentDescription = null, tint = Color.White)
}
Spacer(Modifier.width(10.dp))
Text(actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
@@ -1238,7 +1449,7 @@ private fun DetailScreen(
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
item { ManifestSection(route, selectedDate = state.selectedDate, onNavigate = { openNavigation(context, it) }) }
item { OfflineStaleBanner(state) }
item {
RouteCompletionSection(
@@ -1456,8 +1667,9 @@ private fun photoCountLabel(count: Int): String =
}
@Composable
private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) {
private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status)
val routeDate = route.routeDate ?: selectedDate
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp),
@@ -1465,13 +1677,17 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
) {
Column {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
StatusChip(route.status, stripColor)
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
RouteDateStatusChip(routeDate)
}
}
Text(
"Zlecenie #${route.contractCode ?: route.id}",
style = MaterialTheme.typography.headlineSmall,
@@ -1480,7 +1696,6 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
)
}
}
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
RoutePointBlock(
label = "Załadunek",
@@ -1509,6 +1724,32 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
}
}
@Composable
private fun RouteDateStatusChip(routeDate: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(Color(0xFFE9F2FF), RoundedCornerShape(3.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Outlined.CalendarToday,
contentDescription = null,
tint = Color(0xFF1D4E89),
modifier = Modifier.size(16.dp),
)
Text(
routeDateChipLabel(routeDate),
color = Color(0xFF1D4E89),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun RoutePointBlock(
label: String,
@@ -1,12 +1,18 @@
package pl.firmatpp.kierowca.ui
import java.io.File
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
@@ -18,6 +24,38 @@ fun canCompleteRouteFromDriverApp(
canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
fun routeDateChipLabel(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val date = routeDate
?.takeIf { it.isNotBlank() }
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: return ""
val fallback = date.format(shortDateFormatter)
val days = ChronoUnit.DAYS.between(today, date)
val human = when {
days == 0L -> "dzisiaj"
days == 1L -> "jutro"
days in 2L..7L -> polishWeekdayPhrase(date.dayOfWeek)
else -> null
}
return human?.let { "$it · $fallback" } ?: fallback
}
private fun polishWeekdayPhrase(day: DayOfWeek): String =
when (day) {
DayOfWeek.MONDAY -> "w poniedziałek"
DayOfWeek.TUESDAY -> "we wtorek"
DayOfWeek.WEDNESDAY -> "w środę"
DayOfWeek.THURSDAY -> "w czwartek"
DayOfWeek.FRIDAY -> "w piątek"
DayOfWeek.SATURDAY -> "w sobotę"
DayOfWeek.SUNDAY -> "w niedzielę"
}
fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2
@@ -104,6 +142,16 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when {
hasQueuedUpload -> "Wysyłam zdjęcie..."
reminder?.status == "uploaded" -> "Popraw"
else -> "Zrób zdjęcie"
}
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
@@ -17,9 +17,12 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
@@ -50,12 +53,15 @@ data class DriverUiState(
val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = 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 routeDayLiveUpdateMessage: String? = null,
val feedback: String? = null,
val error: String? = null,
)
@@ -65,6 +71,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application)
private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val liveSyncClient = DriverLiveSyncClient(
@@ -73,6 +80,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } },
)
private var photoUploadsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state
@@ -137,7 +145,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun selectRouteDate(date: String) = loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
fun selectRouteDate(date: String) {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch {
@@ -152,6 +167,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
screen = if (navigateToRoutes) DriverScreen.Routes else it.screen,
driver = response.session.driver,
routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
@@ -168,9 +184,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId)
liveSyncClient.start(driverId, response.realtime)
registerPushTokenIfAvailable(driverId)
}
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
}.onFailure { throwable ->
_state.update {
val apiError = ApiErrorMapper.map(throwable)
@@ -286,6 +303,27 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun uploadDispatchSheetPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val reminder = _state.value.dispatchSheetReminder ?: return
val workDate = reminder.workDate ?: return
if (!reminder.canUpload) return
viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching {
dispatchSheetUploadOutbox.enqueue(
workDate = workDate,
replacePhotoId = reminder.photo?.id,
uri = uri,
source = source,
metadata = metadata,
)
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
}
}
fun retryPhotoUpload(upload: PhotoUploadEntity) {
viewModelScope.launch {
runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
@@ -400,6 +438,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun logout() = runLoading {
photoUploadsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop()
repository.logout()
syncRepository.clearCache()
@@ -427,6 +466,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
private fun observeDispatchSheetUploads(workDate: String?) {
dispatchSheetUploadsJob?.cancel()
if (workDate.isNullOrBlank()) {
_state.update { it.copy(dispatchSheetUploads = emptyList()) }
return
}
dispatchSheetUploadsJob = viewModelScope.launch {
dispatchSheetUploadOutbox.observeVisibleForWorkDate(workDate).collect { uploads ->
_state.update { it.copy(dispatchSheetUploads = uploads) }
}
}
}
private fun refreshCurrentScopeFromSyncState() {
val snapshot = _state.value
if (snapshot.screen == DriverScreen.Routes) {
@@ -457,8 +510,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value
when (hint.scope) {
DriverSyncRepository.SCOPE_ROUTES -> {
if (hint.date == null || hint.date == snapshot.selectedDate) {
if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) {
refreshRoutesSilently()
if (snapshot.screen == DriverScreen.Routes) {
_state.update {
it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.")
}
}
} else {
DriverSyncWorker.enqueue(getApplication(), hint.date, null)
}
@@ -471,6 +529,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
}
}
@@ -491,6 +550,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
override fun onCleared() {
liveSyncClient.close()
dispatchSheetUploadsJob?.cancel()
super.onCleared()
}
}
@@ -0,0 +1,65 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadReceiptDto
class DispatchSheetUploadReceiptVerifierTest {
private val upload = DispatchSheetUploadEntity(
clientRequestId = "6f7a7d10-b7f7-41ab-8f5e-f1afc3e93736",
workDate = "2026-07-03",
replacePhotoId = "12",
localPath = "/tmp/dispatch-sheet.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
@Test
fun confirmsOnlyMatchingReceipt() {
assertTrue(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = upload.contentSha256.uppercase(),
storedAt = "2026-07-03T17:02:00+02:00",
),
),
)
}
@Test
fun rejectsDifferentRequestOrHash() {
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = "other",
serverPhotoId = "55",
contentSha256 = upload.contentSha256,
storedAt = null,
),
),
)
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
storedAt = null,
),
),
)
}
}
@@ -6,6 +6,7 @@ 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.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest {
@@ -34,6 +35,23 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = null))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-13"))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "", hintDate = "2026-03-12"))
}
@Test
fun formatsRouteDateChipHumanFirstWithShortFallback() {
assertEquals("dzisiaj · 30.06", routeDateChipLabel("2026-06-30", today))
assertEquals("jutro · 01.07", routeDateChipLabel("2026-07-01", today))
assertEquals("w poniedziałek · 06.07", routeDateChipLabel("2026-07-06", today))
assertEquals("02.03", routeDateChipLabel("2027-03-02", today))
assertEquals("", routeDateChipLabel("", today))
}
@Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0))
@@ -173,6 +191,21 @@ class DriverUiRulesTest {
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
}
@Test
fun showsDispatchSheetCardOnlyWhenReminderIsDueToday() {
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "missing", dueToday = true)))
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "uploaded", dueToday = true)))
assertFalse(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "not_required", dueToday = false)))
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun labelsDispatchSheetActionByStatusAndLocalUploadQueue() {
assertEquals("Zrób zdjęcie", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = false))
assertEquals("Wysyłam zdjęcie...", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = true))
assertEquals("Popraw", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "uploaded"), hasQueuedUpload = false))
}
private fun route(status: String): DriverRouteDto =
DriverRouteDto(
id = "1",
@@ -205,4 +238,16 @@ class DriverUiRulesTest {
status = status,
serverPhotoId = serverPhotoId,
)
private fun dispatchReminder(status: String, dueToday: Boolean = true): DispatchSheetReminderDto =
DispatchSheetReminderDto(
enabled = dueToday,
dueToday = dueToday,
workDate = "2026-06-30",
reason = if (dueToday) "friday" else null,
availableUntil = "2026-06-30T23:59:59+02:00",
status = status,
photo = null,
canUpload = dueToday,
)
}