From fe2e2e589a5d40f60ae918366208cc218b58664c Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 4 Jul 2026 12:03:34 +0200 Subject: [PATCH] feat: add dispatch sheet reminder to driver app --- .../kierowca/data/DriverRepository.kt | 28 +++ .../kierowca/data/api/MobileDriverApi.kt | 17 ++ .../kierowca/data/model/DriverModels.kt | 44 +++++ .../data/sync/DriverSyncRepository.kt | 1 + .../data/upload/DispatchSheetUploadDao.kt | 66 +++++++ .../data/upload/DispatchSheetUploadEntity.kt | 37 ++++ .../data/upload/DispatchSheetUploadOutbox.kt | 102 +++++++++++ .../DispatchSheetUploadReceiptVerifier.kt | 10 ++ .../data/upload/DispatchSheetUploadWorker.kt | 86 ++++++++++ .../kierowca/data/upload/DriverDatabase.kt | 38 ++++- .../kierowca/sync/DriverSyncWorker.kt | 5 + .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 161 ++++++++++++++++++ .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 11 ++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 47 +++++ .../DispatchSheetUploadReceiptVerifierTest.kt | 65 +++++++ .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 28 +++ 16 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadEntity.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifier.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifierTest.kt 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 c4b5d74..13b02d3 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -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) } 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 c1024d5..cc83ad5 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 @@ -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, 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 58d2a0a..70afe87 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 @@ -46,6 +46,7 @@ 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, @@ -61,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( @@ -196,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?, +) 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 6eb48e7..fa0dd9d 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 @@ -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" } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt new file mode 100644 index 0000000..9094467 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadDao.kt @@ -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> + + @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) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadEntity.kt new file mode 100644 index 0000000..c1d6643 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadEntity.kt @@ -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) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt new file mode 100644 index 0000000..d4da374 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadOutbox.kt @@ -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> = + 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() + .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) } + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifier.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifier.kt new file mode 100644 index 0000000..e87ec7b --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifier.kt @@ -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() +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt new file mode 100644 index 0000000..c0e30a0 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadWorker.kt @@ -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" + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt index 81a745c..dbc5a11 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/DriverDatabase.kt @@ -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 } } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt index 278638c..d75e1ca 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt @@ -42,6 +42,11 @@ class DriverSyncWorker( refreshed = true } } + DriverSyncRepository.SCOPE_SETTINGS, + DriverSyncRepository.SCOPE_DISPATCH_SHEET -> { + syncRepository.bootstrap(scope.date ?: date) + refreshed = true + } } } } 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 4ebf0c4..5c6f5f5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -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,6 +204,7 @@ 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, ) @@ -546,9 +549,40 @@ 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(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) @@ -626,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()) { @@ -645,6 +700,34 @@ 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 @@ -678,6 +761,84 @@ private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modi } } +@Composable +private fun DispatchSheetReminderCard( + reminder: DispatchSheetReminderDto?, + uploads: List, + 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 private fun PhotoQueueBanner(uploads: List, onPhotoQueue: () -> Unit) { val count = queuedPhotoUploadCount(uploads.map { it.status }) 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 f3dd6d1..757572e 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -7,6 +7,7 @@ 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 @@ -141,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() 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 2226ed2..8609f0c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -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,6 +53,8 @@ data class DriverUiState( val selectedPhoto: RoutePhotoDto? = null, val photoUploads: List = emptyList(), val queuedPhotoUploads: List = emptyList(), + val dispatchSheetReminder: DispatchSheetReminderDto? = null, + val dispatchSheetUploads: List = emptyList(), val deletingPhotoIds: Set = emptySet(), val completingRoute: Boolean = false, val imageAuthHeader: String? = null, @@ -66,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( @@ -74,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 = _state @@ -160,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, @@ -179,6 +187,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) liveSyncClient.start(driverId, response.realtime) registerPushTokenIfAvailable(driverId) } + observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) }.onFailure { throwable -> _state.update { val apiError = ApiErrorMapper.map(throwable) @@ -294,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) } @@ -408,6 +438,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun logout() = runLoading { photoUploadsJob?.cancel() + dispatchSheetUploadsJob?.cancel() liveSyncClient.stop() repository.logout() syncRepository.clearCache() @@ -435,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) { @@ -484,6 +529,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() + DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently() } } @@ -504,6 +550,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) override fun onCleared() { liveSyncClient.close() + dispatchSheetUploadsJob?.cancel() super.onCleared() } } diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifierTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifierTest.kt new file mode 100644 index 0000000..984cb29 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/DispatchSheetUploadReceiptVerifierTest.kt @@ -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, + ), + ), + ) + } +} 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 a2c699b..3b07d79 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -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 { @@ -190,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", @@ -222,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, + ) }