From 13159c576ecc76186e9ed364808e72abc490a130 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 7 Jul 2026 12:29:12 +0200 Subject: [PATCH] Implement driver route lifecycle flow --- app/src/main/AndroidManifest.xml | 7 + .../java/pl/firmatpp/kierowca/MainActivity.kt | 7 + .../kierowca/data/AppPreferencesStore.kt | 11 + .../kierowca/data/DriverRepository.kt | 16 + .../kierowca/data/api/MobileDriverApi.kt | 27 + .../kierowca/data/model/DriverModels.kt | 61 +++ .../kierowca/data/upload/DriverDatabase.kt | 63 ++- .../kierowca/data/upload/PhotoUploadEntity.kt | 1 + .../kierowca/data/upload/PhotoUploadOutbox.kt | 3 +- .../kierowca/data/upload/RouteActionDao.kt | 44 ++ .../kierowca/data/upload/RouteActionEntity.kt | 41 ++ .../kierowca/data/upload/RouteActionOutbox.kt | 66 +++ .../kierowca/data/upload/RouteActionWorker.kt | 132 +++++ .../kierowca/data/upload/RoutePointDao.kt | 50 ++ .../kierowca/data/upload/RoutePointEntity.kt | 40 ++ .../kierowca/data/upload/RoutePointOutbox.kt | 61 +++ .../kierowca/data/upload/RoutePointWorker.kt | 90 ++++ .../tracking/ActiveRouteTrackingService.kt | 300 +++++++++++ .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 481 ++++++++++++++---- .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 51 ++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 188 ++++++- .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 57 +++ 22 files changed, 1687 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointEntity.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f09c3dc..d4cad20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + + + diff --git a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt index 37be749..0b532c5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt @@ -14,11 +14,13 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme class MainActivity : ComponentActivity() { private var notificationRouteId by mutableStateOf(null) + private var notificationRouteTarget by mutableStateOf(null) private var notificationLeaveRequestId by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) + notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) setContent { val viewModel: DriverViewModel = viewModel() @@ -28,6 +30,7 @@ class MainActivity : ComponentActivity() { DriverApp( viewModel = viewModel, initialRouteId = notificationRouteId, + initialRouteTarget = notificationRouteTarget, initialLeaveRequestId = notificationLeaveRequestId, ) } @@ -38,11 +41,15 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) + notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) } companion object { const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID" + const val EXTRA_ROUTE_TARGET = "pl.firmatpp.kierowca.EXTRA_ROUTE_TARGET" const val EXTRA_LEAVE_REQUEST_ID = "pl.firmatpp.kierowca.EXTRA_LEAVE_REQUEST_ID" + const val ROUTE_TARGET_DETAIL = "detail" + const val ROUTE_TARGET_FINISH = "finish" } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt b/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt index 78cdf0e..80f49fb 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/AppPreferencesStore.kt @@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -18,13 +19,23 @@ class AppPreferencesStore( constructor(context: Context) : this(context.driverAppPreferencesDataStore) private val themeModeKey = stringPreferencesKey("theme_mode") + private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled") val themeMode: Flow = dataStore.data .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } + val routeProgressNotificationEnabled: Flow = dataStore.data + .map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true } + suspend fun setThemeMode(themeMode: AppThemeMode) { dataStore.edit { preferences -> preferences[themeModeKey] = themeMode.storedValue } } + + suspend fun setRouteProgressNotificationEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[routeProgressNotificationEnabledKey] = enabled + } + } } 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 74208e3..67e689d 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -19,6 +19,7 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto +import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.OtpResponse @@ -26,7 +27,10 @@ import pl.firmatpp.kierowca.data.model.PhotoUploadResponse import pl.firmatpp.kierowca.data.model.PushTokenBody import pl.firmatpp.kierowca.data.model.RealtimeStatusBody import pl.firmatpp.kierowca.data.model.RequestOtpBody +import pl.firmatpp.kierowca.data.model.RoutePointBatchBody +import pl.firmatpp.kierowca.data.model.RoutePointsResponse import pl.firmatpp.kierowca.data.model.RouteResponse +import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity @@ -69,6 +73,15 @@ class DriverRepository( suspend fun completeRoute(routeId: String) = api.completeRoute(authHeader(requireToken()), routeId) + suspend fun startRoute(routeId: String, body: StartRouteBody) = + api.startRoute(authHeader(requireToken()), routeId, body) + + suspend fun finishRoute(routeId: String, body: FinishRouteBody) = + api.finishRoute(authHeader(requireToken()), routeId, body) + + suspend fun storeRoutePoints(routeId: String, body: RoutePointBatchBody): RoutePointsResponse = + api.storeRoutePoints(authHeader(requireToken()), routeId, body) + suspend fun updateNotificationPreferences(notifyNewRoutes: Boolean): NotificationPreferencesDto = api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes)) @@ -95,6 +108,7 @@ class DriverRepository( routeId: String, uri: Uri, source: String, + stage: String = "other", metadata: PhotoUploadMetadata = PhotoUploadMetadata(), ): PhotoUploadResponse { val resolver = context.contentResolver @@ -115,6 +129,7 @@ class DriverRepository( clientRequestId.toPlainTextBody(), contentSha256.toPlainTextBody(), sourceBody, + stage.toPlainTextBody(), metadataParts["takenAt"], metadataParts["latitude"], metadataParts["longitude"], @@ -141,6 +156,7 @@ class DriverRepository( upload.clientRequestId.toPlainTextBody(), upload.contentSha256.toPlainTextBody(), upload.source.toPlainTextBody(), + upload.stage.toPlainTextBody(), metadataParts["takenAt"], metadataParts["latitude"], metadataParts["longitude"], 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 bfd4d7b..bc606fa 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 @@ -9,6 +9,7 @@ import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody import pl.firmatpp.kierowca.data.model.CompleteRouteResponse import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse +import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse import pl.firmatpp.kierowca.data.model.LeaveRequestResponse import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse @@ -19,7 +20,11 @@ import pl.firmatpp.kierowca.data.model.PhotoUploadResponse import pl.firmatpp.kierowca.data.model.PushTokenBody import pl.firmatpp.kierowca.data.model.RealtimeStatusBody import pl.firmatpp.kierowca.data.model.RequestOtpBody +import pl.firmatpp.kierowca.data.model.RouteActionResponse +import pl.firmatpp.kierowca.data.model.RoutePointBatchBody +import pl.firmatpp.kierowca.data.model.RoutePointsResponse import pl.firmatpp.kierowca.data.model.RouteResponse +import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpResponse @@ -102,6 +107,27 @@ interface MobileDriverApi { @Path("routeId") routeId: String, ): CompleteRouteResponse + @POST("mobile/driver/routes/{routeId}/start") + suspend fun startRoute( + @Header("Authorization") authorization: String, + @Path("routeId") routeId: String, + @Body body: StartRouteBody, + ): RouteActionResponse + + @POST("mobile/driver/routes/{routeId}/finish") + suspend fun finishRoute( + @Header("Authorization") authorization: String, + @Path("routeId") routeId: String, + @Body body: FinishRouteBody, + ): RouteActionResponse + + @POST("mobile/driver/routes/{routeId}/points") + suspend fun storeRoutePoints( + @Header("Authorization") authorization: String, + @Path("routeId") routeId: String, + @Body body: RoutePointBatchBody, + ): RoutePointsResponse + @GET("mobile/driver/sync-state") suspend fun syncState( @Header("Authorization") authorization: String, @@ -142,6 +168,7 @@ interface MobileDriverApi { @Part("clientRequestId") clientRequestId: RequestBody, @Part("contentSha256") contentSha256: RequestBody, @Part("source") source: RequestBody, + @Part("stage") stage: RequestBody?, @Part("takenAt") takenAt: RequestBody?, @Part("latitude") latitude: RequestBody?, @Part("longitude") longitude: RequestBody?, 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 8a78d96..16e8d14 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 @@ -232,6 +232,10 @@ data class DriverRouteDto( val contractCode: String?, val relationLabel: String, val status: String, + val loadingWeight: Double? = null, + val unloadingWeight: Double? = null, + val trackingStatus: String? = null, + val arrivedAt: String? = null, val distanceKm: Double?, val notes: String?, val truck: TruckDto?, @@ -240,6 +244,13 @@ data class DriverRouteDto( val originNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null, val photos: List = emptyList(), + val photosByStage: RoutePhotosByStageDto? = null, +) + +data class RoutePhotosByStageDto( + val loading: List = emptyList(), + val unloading: List = emptyList(), + val other: List = emptyList(), ) data class TruckDto( @@ -262,6 +273,7 @@ data class RoutePhotoDto( val clientRequestId: String? = null, val contentSha256: String? = null, val source: String, + val stage: String = "other", val mimeType: String?, val size: Long, val url: String, @@ -286,6 +298,55 @@ data class PhotoUploadReceiptDto( val storedAt: String?, ) +data class StartRouteBody( + val clientActionId: String, + val loadingWeight: Double, + val occurredAt: String, + val photoClientRequestIds: List, +) + +data class FinishRouteBody( + val clientActionId: String, + val unloadingWeight: Double, + val occurredAt: String, + val photoClientRequestIds: List, +) + +data class RouteActionResponse( + val ok: Boolean, + val alreadyProcessed: Boolean, + val route: DriverRouteDto, +) + +data class RoutePointBatchBody( + val clientBatchId: String, + val points: List, +) + +data class RoutePointDto( + val clientPointId: String, + val trip_id: String, + val driver_id: String, + val lat: Double, + val lon: Double, + val accuracy: Double, + val speed: Double?, + val bearing: Double?, + val timestamp: Long, + val battery: Int?, + val charging: Boolean, +) + +data class RoutePointsResponse( + val ok: Boolean, + val stored: Int, + val progress: Double?, + val remainingDistanceMeters: Int?, + val arrived: Boolean, + val offRoute: Boolean, + val routeVersion: Long?, +) + data class DispatchSheetUploadResponse( val photo: DispatchSheetPhotoDto, val reminder: DispatchSheetReminderDto, 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 dbc5a11..96084e3 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 @@ -15,16 +15,20 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity entities = [ PhotoUploadEntity::class, DispatchSheetUploadEntity::class, + RouteActionEntity::class, + RoutePointEntity::class, DriverBootstrapCacheEntity::class, DriverRouteCacheEntity::class, DriverSyncStateEntity::class, ], - version = 3, + version = 5, exportSchema = false, ) abstract class DriverDatabase : RoomDatabase() { abstract fun photoUploadDao(): PhotoUploadDao abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao + abstract fun routeActionDao(): RouteActionDao + abstract fun routePointDao(): RoutePointDao abstract fun driverCacheDao(): DriverCacheDao companion object { @@ -105,13 +109,68 @@ abstract class DriverDatabase : RoomDatabase() { } } + private val migration3To4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE photo_uploads ADD COLUMN stage TEXT NOT NULL DEFAULT 'other'") + } + } + + private val migration4To5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS route_actions ( + clientActionId TEXT NOT NULL PRIMARY KEY, + routeId TEXT NOT NULL, + action TEXT NOT NULL, + weight REAL NOT NULL, + occurredAt TEXT NOT NULL, + photoClientRequestIdsJson TEXT NOT NULL, + status TEXT NOT NULL, + attemptCount INTEGER NOT NULL, + lastError TEXT, + createdAtEpochMillis INTEGER NOT NULL, + updatedAtEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_routeId ON route_actions(routeId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_status ON route_actions(status)") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS route_points ( + clientPointId TEXT NOT NULL PRIMARY KEY, + routeId TEXT NOT NULL, + driverId TEXT NOT NULL, + lat REAL NOT NULL, + lon REAL NOT NULL, + accuracy REAL NOT NULL, + speed REAL, + bearing REAL, + timestamp INTEGER NOT NULL, + battery INTEGER, + charging INTEGER NOT NULL, + status TEXT NOT NULL, + attemptCount INTEGER NOT NULL, + lastError TEXT, + createdAtEpochMillis INTEGER NOT NULL, + updatedAtEpochMillis INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL("CREATE INDEX IF NOT EXISTS index_route_points_routeId ON route_points(routeId)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_route_points_status ON route_points(status)") + db.execSQL("CREATE INDEX IF NOT EXISTS index_route_points_routeId_status ON route_points(routeId, status)") + } + } + fun get(context: Context): DriverDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( context.applicationContext, DriverDatabase::class.java, "driver-local-outbox.db", - ).addMigrations(migration1To2, migration2To3).build().also { instance = it } + ).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it } } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt index 639aedc..8f002c6 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadEntity.kt @@ -16,6 +16,7 @@ data class PhotoUploadEntity( val routeId: String, val localPath: String, val source: String, + val stage: String = "other", val takenAt: String?, val latitude: Double?, val longitude: Double?, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt index a2419eb..b6131c1 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/PhotoUploadOutbox.kt @@ -28,7 +28,7 @@ class PhotoUploadOutbox( fun observeQueuedUploads(): Flow> = dao.observeQueuedUploads() - suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata): PhotoUploadEntity { + suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata, stage: String = "other"): PhotoUploadEntity { val clientRequestId = UUID.randomUUID().toString() val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val extension = when (mimeType) { @@ -44,6 +44,7 @@ class PhotoUploadOutbox( routeId = routeId, localPath = localFile.absolutePath, source = source, + stage = stage, takenAt = metadata.takenAt, latitude = metadata.latitude, longitude = metadata.longitude, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt new file mode 100644 index 0000000..177f457 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt @@ -0,0 +1,44 @@ +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 RouteActionDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(action: RouteActionEntity) + + @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") + suspend fun find(clientActionId: String): RouteActionEntity? + + @Query( + """ + SELECT * FROM route_actions + WHERE routeId = :routeId + AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT') + ORDER BY createdAtEpochMillis DESC + """, + ) + fun observeUnresolvedForRoute(routeId: String): Flow> + + @Query( + """ + UPDATE route_actions + SET status = :status, + lastError = :lastError, + attemptCount = attemptCount + :attemptIncrement, + updatedAtEpochMillis = :updatedAt + WHERE clientActionId = :clientActionId + """, + ) + suspend fun updateStatus( + clientActionId: String, + status: String, + lastError: String?, + attemptIncrement: Int, + updatedAt: Long = System.currentTimeMillis(), + ) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt new file mode 100644 index 0000000..8d92547 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionEntity.kt @@ -0,0 +1,41 @@ +package pl.firmatpp.kierowca.data.upload + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +object RouteActionStatus { + const val Pending = "PENDING" + const val WaitingForPhotos = "WAITING_FOR_PHOTOS" + const val Syncing = "SYNCING" + const val Confirmed = "CONFIRMED" + const val FailedRetryable = "FAILED_RETRYABLE" + const val FailedPermanent = "FAILED_PERMANENT" + const val FailedConflict = "FAILED_CONFLICT" +} + +object RouteActionType { + const val Start = "start" + const val Finish = "finish" +} + +@Entity( + tableName = "route_actions", + indices = [ + Index(value = ["routeId"]), + Index(value = ["status"]), + ], +) +data class RouteActionEntity( + @PrimaryKey val clientActionId: String, + val routeId: String, + val action: String, + val weight: Double, + val occurredAt: String, + val photoClientRequestIdsJson: String, + val status: String = RouteActionStatus.Pending, + val attemptCount: Int = 0, + val lastError: String? = null, + val createdAtEpochMillis: Long = System.currentTimeMillis(), + val updatedAtEpochMillis: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt new file mode 100644 index 0000000..e6307ef --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionOutbox.kt @@ -0,0 +1,66 @@ +package pl.firmatpp.kierowca.data.upload + +import android.content.Context +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 com.google.gson.Gson +import java.time.Instant +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.Flow + +class RouteActionOutbox( + private val context: Context, + private val dao: RouteActionDao = DriverDatabase.get(context).routeActionDao(), + private val workManager: WorkManager = WorkManager.getInstance(context), + private val gson: Gson = Gson(), +) { + fun observeUnresolvedForRoute(routeId: String): Flow> = + dao.observeUnresolvedForRoute(routeId) + + suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List): RouteActionEntity = + enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) + + suspend fun enqueueFinish(routeId: String, unloadingWeight: Double, photoClientRequestIds: List): RouteActionEntity = + enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds) + + private suspend fun enqueue( + action: String, + routeId: String, + weight: Double, + photoClientRequestIds: List, + ): RouteActionEntity { + val entity = RouteActionEntity( + clientActionId = UUID.randomUUID().toString(), + routeId = routeId, + action = action, + weight = weight, + occurredAt = Instant.now().toString(), + photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), + ) + + dao.upsert(entity) + enqueueWorker(entity.clientActionId) + + return entity + } + + private fun enqueueWorker(clientActionId: String) { + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId)) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + + workManager.enqueueUniqueWork( + RouteActionWorker.uniqueWorkName(clientActionId), + ExistingWorkPolicy.REPLACE, + request, + ) + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt new file mode 100644 index 0000000..3df1858 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt @@ -0,0 +1,132 @@ +package pl.firmatpp.kierowca.data.upload + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.google.gson.Gson +import pl.firmatpp.kierowca.data.ApiErrorKind +import pl.firmatpp.kierowca.data.ApiErrorMapper +import pl.firmatpp.kierowca.data.DriverRepository +import pl.firmatpp.kierowca.data.model.FinishRouteBody +import pl.firmatpp.kierowca.data.model.RouteActionResponse +import pl.firmatpp.kierowca.data.model.StartRouteBody +import pl.firmatpp.kierowca.data.sync.DriverSyncRepository +import pl.firmatpp.kierowca.diagnostics.AppDiagnostics +import pl.firmatpp.kierowca.sync.DriverSyncWorker + +class RouteActionWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + private val database = DriverDatabase.get(appContext) + private val dao = database.routeActionDao() + private val photoDao = database.photoUploadDao() + private val repository = DriverRepository(appContext) + private val syncRepository = DriverSyncRepository(appContext, repository) + private val gson = Gson() + + override suspend fun doWork(): Result { + val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure() + val action = dao.find(clientActionId) ?: return Result.failure() + val photoClientRequestIds = photoClientRequestIds(action) + + val waitingForPhotos = photoClientRequestIds + .mapNotNull { photoDao.find(it) } + .any { it.status != PhotoUploadStatus.Confirmed.storageValue } + + if (waitingForPhotos) { + dao.updateStatus( + clientActionId = clientActionId, + status = RouteActionStatus.WaitingForPhotos, + lastError = "Czekam na wysłanie zdjęć etapu.", + attemptIncrement = 0, + ) + return Result.retry() + } + + dao.updateStatus( + clientActionId = clientActionId, + status = RouteActionStatus.Syncing, + lastError = null, + attemptIncrement = 1, + ) + + return runCatching { + val response = sendAction(action, photoClientRequestIds) + dao.updateStatus( + clientActionId = clientActionId, + status = RouteActionStatus.Confirmed, + lastError = null, + attemptIncrement = 0, + ) + syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate) + DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) + Result.success() + }.getOrElse { throwable -> + val error = ApiErrorMapper.map(throwable) + AppDiagnostics.reportNonFatal( + throwable = throwable, + operation = "route_action_worker", + keys = mapOf( + "client_action_id" to clientActionId, + "route_id" to action.routeId, + "route_action" to action.action, + "api_error_kind" to error.kind.name, + "api_error_code" to error.code, + "api_status_code" to error.statusCode, + "retryable" to error.retryable, + ), + ) + val status = when { + error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict + error.retryable -> RouteActionStatus.FailedRetryable + else -> RouteActionStatus.FailedPermanent + } + dao.updateStatus( + clientActionId = clientActionId, + status = status, + lastError = error.message, + attemptIncrement = 0, + ) + + if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure() + } + } + + private suspend fun sendAction(action: RouteActionEntity, photoClientRequestIds: List): RouteActionResponse = + when (action.action) { + RouteActionType.Start -> repository.startRoute( + routeId = action.routeId, + body = StartRouteBody( + clientActionId = action.clientActionId, + loadingWeight = action.weight, + occurredAt = action.occurredAt, + photoClientRequestIds = photoClientRequestIds, + ), + ) + RouteActionType.Finish -> repository.finishRoute( + routeId = action.routeId, + body = FinishRouteBody( + clientActionId = action.clientActionId, + unloadingWeight = action.weight, + occurredAt = action.occurredAt, + photoClientRequestIds = photoClientRequestIds, + ), + ) + else -> error("Nieznana akcja kursu: ${action.action}") + } + + private fun photoClientRequestIds(action: RouteActionEntity): List = + runCatching { + gson.fromJson(action.photoClientRequestIdsJson, Array::class.java)?.toList().orEmpty() + }.getOrDefault(emptyList()) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + + companion object { + const val KEY_CLIENT_ACTION_ID = "clientActionId" + + fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId" + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt new file mode 100644 index 0000000..1a5d1e3 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointDao.kt @@ -0,0 +1,50 @@ +package pl.firmatpp.kierowca.data.upload + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface RoutePointDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(point: RoutePointEntity) + + @Query( + """ + SELECT * FROM route_points + WHERE routeId = :routeId + AND status != 'CONFIRMED' + ORDER BY timestamp ASC + LIMIT :limit + """, + ) + suspend fun pendingForRoute(routeId: String, limit: Int): List + + @Query( + """ + SELECT COUNT(*) FROM route_points + WHERE routeId = :routeId + AND status != 'CONFIRMED' + """, + ) + suspend fun pendingCount(routeId: String): Int + + @Query( + """ + UPDATE route_points + SET status = :status, + lastError = :lastError, + attemptCount = attemptCount + :attemptIncrement, + updatedAtEpochMillis = :updatedAt + WHERE clientPointId IN (:clientPointIds) + """, + ) + suspend fun updateStatus( + clientPointIds: List, + status: String, + lastError: String?, + attemptIncrement: Int, + updatedAt: Long = System.currentTimeMillis(), + ) +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointEntity.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointEntity.kt new file mode 100644 index 0000000..d7875e7 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointEntity.kt @@ -0,0 +1,40 @@ +package pl.firmatpp.kierowca.data.upload + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +object RoutePointStatus { + const val Pending = "PENDING" + const val Syncing = "SYNCING" + const val Confirmed = "CONFIRMED" + const val FailedRetryable = "FAILED_RETRYABLE" + const val FailedPermanent = "FAILED_PERMANENT" +} + +@Entity( + tableName = "route_points", + indices = [ + Index(value = ["routeId"]), + Index(value = ["status"]), + Index(value = ["routeId", "status"]), + ], +) +data class RoutePointEntity( + @PrimaryKey val clientPointId: String, + val routeId: String, + val driverId: String, + val lat: Double, + val lon: Double, + val accuracy: Double, + val speed: Double?, + val bearing: Double?, + val timestamp: Long, + val battery: Int?, + val charging: Boolean, + val status: String = RoutePointStatus.Pending, + val attemptCount: Int = 0, + val lastError: String? = null, + val createdAtEpochMillis: Long = System.currentTimeMillis(), + val updatedAtEpochMillis: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt new file mode 100644 index 0000000..1193f04 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointOutbox.kt @@ -0,0 +1,61 @@ +package pl.firmatpp.kierowca.data.upload + +import android.content.Context +import android.location.Location +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.util.UUID +import java.util.concurrent.TimeUnit + +class RoutePointOutbox( + private val context: Context, + private val dao: RoutePointDao = DriverDatabase.get(context).routePointDao(), + private val workManager: WorkManager = WorkManager.getInstance(context), +) { + suspend fun enqueue( + routeId: String, + driverId: String, + location: Location, + battery: Int?, + charging: Boolean, + uploadDelaySeconds: Long, + ) { + dao.upsert( + RoutePointEntity( + clientPointId = UUID.randomUUID().toString(), + routeId = routeId, + driverId = driverId, + lat = location.latitude, + lon = location.longitude, + accuracy = if (location.hasAccuracy()) location.accuracy.toDouble() else 0.0, + speed = if (location.hasSpeed()) location.speed.toDouble() else null, + bearing = if (location.hasBearing()) location.bearing.toDouble() else null, + timestamp = ((location.time.takeIf { it > 0L } ?: System.currentTimeMillis()) / 1000L), + battery = battery, + charging = charging, + ), + ) + + enqueueWorker(routeId, uploadDelaySeconds) + } + + fun enqueueWorker(routeId: String, delaySeconds: Long = 20L) { + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(RoutePointWorker.KEY_ROUTE_ID to routeId)) + .setInitialDelay(delaySeconds.coerceAtLeast(0L), TimeUnit.SECONDS) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + + workManager.enqueueUniqueWork( + RoutePointWorker.uniqueWorkName(routeId), + ExistingWorkPolicy.KEEP, + request, + ) + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt new file mode 100644 index 0000000..6fe36d4 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RoutePointWorker.kt @@ -0,0 +1,90 @@ +package pl.firmatpp.kierowca.data.upload + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import java.util.UUID +import pl.firmatpp.kierowca.data.ApiErrorKind +import pl.firmatpp.kierowca.data.ApiErrorMapper +import pl.firmatpp.kierowca.data.DriverRepository +import pl.firmatpp.kierowca.data.model.RoutePointBatchBody +import pl.firmatpp.kierowca.data.model.RoutePointDto +import pl.firmatpp.kierowca.diagnostics.AppDiagnostics +import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService + +class RoutePointWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + private val dao = DriverDatabase.get(appContext).routePointDao() + private val repository = DriverRepository(appContext) + + override suspend fun doWork(): Result { + val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure() + val points = dao.pendingForRoute(routeId, BATCH_LIMIT) + if (points.isEmpty()) return Result.success() + + val pointIds = points.map { it.clientPointId } + dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1) + + return runCatching { + val response = repository.storeRoutePoints( + routeId = routeId, + body = RoutePointBatchBody( + clientBatchId = UUID.randomUUID().toString(), + points = points.map { it.toDto() }, + ), + ) + dao.updateStatus(pointIds, RoutePointStatus.Confirmed, null, attemptIncrement = 0) + + if (response.arrived) { + ActiveRouteTrackingService.markArrived(applicationContext, routeId) + } + if (dao.pendingCount(routeId) > 0) { + RoutePointOutbox(applicationContext).enqueueWorker(routeId, delaySeconds = 1L) + } + + Result.success() + }.getOrElse { throwable -> + val error = ApiErrorMapper.map(throwable) + AppDiagnostics.reportNonFatal( + throwable = throwable, + operation = "route_point_worker", + keys = mapOf( + "route_id" to routeId, + "batch_size" to points.size, + "api_error_kind" to error.kind.name, + "api_error_code" to error.code, + "api_status_code" to error.statusCode, + "retryable" to error.retryable, + ), + ) + val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent + dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0) + + if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure() + } + } + + private fun RoutePointEntity.toDto(): RoutePointDto = + RoutePointDto( + clientPointId = clientPointId, + trip_id = routeId, + driver_id = driverId, + lat = lat, + lon = lon, + accuracy = accuracy, + speed = speed, + bearing = bearing, + timestamp = timestamp, + battery = battery, + charging = charging, + ) + + companion object { + const val KEY_ROUTE_ID = "routeId" + private const val BATCH_LIMIT = 100 + + fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId" + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt b/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt new file mode 100644 index 0000000..4b52860 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/tracking/ActiveRouteTrackingService.kt @@ -0,0 +1,300 @@ +package pl.firmatpp.kierowca.tracking + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.BatteryManager +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import java.util.Locale +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import pl.firmatpp.kierowca.MainActivity +import pl.firmatpp.kierowca.R +import pl.firmatpp.kierowca.data.model.DriverDto +import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.upload.RoutePointOutbox + +class ActiveRouteTrackingService : Service(), LocationListener { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private lateinit var locationManager: LocationManager + private lateinit var routePointOutbox: RoutePointOutbox + + private var routeId: String? = null + private var driverId: String? = null + private var routeLabel: String = "Kurs aktywny" + private var destinationLat: Double? = null + private var destinationLon: Double? = null + private var totalDistanceMeters: Double? = null + private var progressEnabled: Boolean = true + private var arrived: Boolean = false + private var lastRemainingMeters: Float? = null + + override fun onCreate() { + super.onCreate() + locationManager = getSystemService(LocationManager::class.java) + routePointOutbox = RoutePointOutbox(this) + ensureNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + stopSelf() + return START_NOT_STICKY + } + ACTION_MARK_ARRIVED -> { + val id = intent.getStringExtra(EXTRA_ROUTE_ID) + if (!id.isNullOrBlank() && (routeId == null || id == routeId)) { + routeId = id + arrived = true + startForegroundCompat(buildNotification()) + } + return START_STICKY + } + else -> { + routeId = intent?.getStringExtra(EXTRA_ROUTE_ID) ?: routeId + driverId = intent?.getStringExtra(EXTRA_DRIVER_ID) ?: driverId + routeLabel = intent?.getStringExtra(EXTRA_ROUTE_LABEL) ?: routeLabel + destinationLat = intent?.takeIf { it.hasExtra(EXTRA_DESTINATION_LAT) }?.getDoubleExtra(EXTRA_DESTINATION_LAT, 0.0) + ?: destinationLat + destinationLon = intent?.takeIf { it.hasExtra(EXTRA_DESTINATION_LON) }?.getDoubleExtra(EXTRA_DESTINATION_LON, 0.0) + ?: destinationLon + totalDistanceMeters = intent?.takeIf { it.hasExtra(EXTRA_TOTAL_DISTANCE_METERS) }?.getDoubleExtra(EXTRA_TOTAL_DISTANCE_METERS, 0.0) + ?.takeIf { it > 0.0 } + ?: totalDistanceMeters + progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled + arrived = false + startForegroundCompat(buildNotification()) + requestLocationUpdates() + return START_STICKY + } + } + } + + override fun onLocationChanged(location: Location) { + val activeRouteId = routeId ?: return + val activeDriverId = driverId ?: return + val remaining = remainingMeters(location) + lastRemainingMeters = remaining + + if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { + arrived = true + startForegroundCompat(buildNotification()) + } else { + startForegroundCompat(buildNotification()) + } + + val (battery, charging) = batterySnapshot() + val uploadDelaySeconds = uploadDelaySeconds(location, remaining) + scope.launch { + routePointOutbox.enqueue( + routeId = activeRouteId, + driverId = activeDriverId, + location = location, + battery = battery, + charging = charging, + uploadDelaySeconds = uploadDelaySeconds, + ) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + runCatching { locationManager.removeUpdates(this) } + scope.cancel() + super.onDestroy() + } + + private fun requestLocationUpdates() { + if (!hasLocationPermission()) { + return + } + runCatching { + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, LOCATION_INTERVAL_MS, 0f, this) + } + runCatching { + locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, LOCATION_INTERVAL_MS, 0f, this) + } + } + + private fun buildNotification(): Notification { + val activeRouteId = routeId + val target = if (arrived) MainActivity.ROUTE_TARGET_FINISH else MainActivity.ROUTE_TARGET_DETAIL + val contentIntent = PendingIntent.getActivity( + this, + if (arrived) 202 else 201, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(MainActivity.EXTRA_ROUTE_ID, activeRouteId) + putExtra(MainActivity.EXTRA_ROUTE_TARGET, target) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val title = if (arrived) "Jesteś u celu" else routeLabel + val text = when { + arrived -> "Otwórz zakończenie kursu i dodaj wagę oraz zdjęcia." + !progressEnabled -> "Kurs aktywny" + lastRemainingMeters != null -> "Do celu: ${formatDistance(lastRemainingMeters!!.toDouble())}" + else -> "Kurs aktywny" + } + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(text) + .setContentIntent(contentIntent) + .setOngoing(!arrived) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setPriority(NotificationCompat.PRIORITY_LOW) + + val progress = progressPercent() + if (!arrived && progressEnabled && progress != null) { + builder.setProgress(100, progress, false) + } + + return builder.build() + } + + private fun startForegroundCompat(notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + "Aktywny kurs", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Postęp aktywnego kursu i lokalizacja w tle" + } + manager.createNotificationChannel(channel) + } + + private fun remainingMeters(location: Location): Float? { + val lat = destinationLat ?: return null + val lon = destinationLon ?: return null + val target = Location("destination").apply { + latitude = lat + longitude = lon + } + return location.distanceTo(target) + } + + private fun arrivalRadiusMeters(location: Location): Float = + maxOf(120f, if (location.hasAccuracy()) location.accuracy * 2f else 0f) + + private fun progressPercent(): Int? { + val total = totalDistanceMeters ?: return null + val remaining = lastRemainingMeters ?: return null + if (total <= 0.0) return null + + return ((1.0 - (remaining / total)).coerceIn(0.0, 1.0) * 100).toInt() + } + + private fun uploadDelaySeconds(location: Location, remaining: Float?): Long { + val speed = if (location.hasSpeed()) location.speed else 0f + return when { + arrived || remaining != null && remaining <= 2_500f -> 5L + speed < 0.8f -> 45L + speed > 22f -> 20L + else -> 20L + } + } + + private fun batterySnapshot(): Pair { + val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 + val status = batteryIntent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1 + val percent = if (level >= 0 && scale > 0) ((level * 100f) / scale).toInt().coerceIn(0, 100) else null + val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + + return percent to charging + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + + private fun formatDistance(meters: Double): String = + if (meters >= 1000.0) { + String.format(Locale("pl", "PL"), "%.1f km", meters / 1000.0) + } else { + "${meters.toInt().coerceAtLeast(0)} m" + } + + companion object { + private const val CHANNEL_ID = "active_route_tracking" + private const val NOTIFICATION_ID = 7401 + private const val LOCATION_INTERVAL_MS = 5_000L + + private const val ACTION_START = "pl.firmatpp.kierowca.tracking.START" + private const val ACTION_STOP = "pl.firmatpp.kierowca.tracking.STOP" + private const val ACTION_MARK_ARRIVED = "pl.firmatpp.kierowca.tracking.MARK_ARRIVED" + + private const val EXTRA_ROUTE_ID = "routeId" + private const val EXTRA_DRIVER_ID = "driverId" + private const val EXTRA_ROUTE_LABEL = "routeLabel" + private const val EXTRA_DESTINATION_LAT = "destinationLat" + private const val EXTRA_DESTINATION_LON = "destinationLon" + private const val EXTRA_TOTAL_DISTANCE_METERS = "totalDistanceMeters" + private const val EXTRA_PROGRESS_ENABLED = "progressEnabled" + + fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) { + val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_ROUTE_ID, route.id) + putExtra(EXTRA_DRIVER_ID, driver.id) + putExtra(EXTRA_ROUTE_LABEL, route.contractCode?.let { "Kurs #$it" } ?: route.relationLabel) + putExtra(EXTRA_PROGRESS_ENABLED, progressEnabled) + route.destinationNavigation?.latitude?.let { putExtra(EXTRA_DESTINATION_LAT, it) } + route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) } + route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) } + } + ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + context.startService( + Intent(context, ActiveRouteTrackingService::class.java).apply { + action = ACTION_STOP + }, + ) + } + + fun markArrived(context: Context, routeId: String) { + val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { + action = ACTION_MARK_ARRIVED + putExtra(EXTRA_ROUTE_ID, routeId) + } + ContextCompat.startForegroundService(context, intent) + } + } +} 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 e563a7d..ae794d8 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -147,13 +147,19 @@ 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.data.upload.RouteActionEntity import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.TppTheme @Composable -fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initialLeaveRequestId: String? = null) { +fun DriverApp( + viewModel: DriverViewModel, + initialRouteId: String? = null, + initialRouteTarget: String? = null, + initialLeaveRequestId: String? = null, +) { val state by viewModel.state.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current @@ -198,8 +204,8 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initia enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp, onCode = viewModel::updateOtpCode, ) - LaunchedEffect(initialRouteId) { - viewModel.openRouteFromNotification(initialRouteId) + LaunchedEffect(initialRouteId, initialRouteTarget) { + viewModel.openRouteFromNotification(initialRouteId, initialRouteTarget) } LaunchedEffect(initialLeaveRequestId, state.leaveRequestsConfig) { if (!initialLeaveRequestId.isNullOrBlank() && DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) { @@ -241,17 +247,47 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initia }, onOpenNotificationSettings = { openAppNotificationSettings(context) }, onThemeModeChanged = viewModel::setThemeMode, + onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled, ) DriverScreen.Detail -> DetailScreen( state, viewModel::back, - viewModel::uploadPhoto, + { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata) }, viewModel::openPhoto, viewModel::deletePhoto, viewModel::deleteConfirmedUpload, viewModel::retryPhotoUpload, viewModel::refreshSelectedRoute, - viewModel::completeSelectedRoute, + viewModel::openStartRoute, + viewModel::openFinishRoute, + ) + DriverScreen.StartRoute -> RouteStageScreen( + state = state, + title = "Rozpocznij kurs", + stage = "loading", + weightLabel = "Waga na załadunku", + submitLabel = "Rozpocznij kurs", + onBack = viewModel::back, + onWeightChange = viewModel::updateRouteStageWeight, + onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") }, + onPhoto = viewModel::openPhoto, + onDeleteUpload = viewModel::deleteConfirmedUpload, + onRetryUpload = viewModel::retryPhotoUpload, + onSubmit = viewModel::submitStartRoute, + ) + DriverScreen.FinishRoute -> RouteStageScreen( + state = state, + title = "Zakończ kurs", + stage = "unloading", + weightLabel = "Waga na rozładunku", + submitLabel = "Zakończ kurs", + onBack = viewModel::back, + onWeightChange = viewModel::updateRouteStageWeight, + onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") }, + onPhoto = viewModel::openPhoto, + onDeleteUpload = viewModel::deleteConfirmedUpload, + onRetryUpload = viewModel::retryPhotoUpload, + onSubmit = viewModel::submitFinishRoute, ) DriverScreen.Photo -> PhotoScreen(state, viewModel::back) DriverScreen.PhotoQueue -> PhotoQueueScreen( @@ -1671,6 +1707,7 @@ private fun ProfileScreen( onNewRouteNotificationsChanged: (Boolean) -> Unit, onOpenNotificationSettings: () -> Unit, onThemeModeChanged: (AppThemeMode) -> Unit, + onRouteProgressNotificationChanged: (Boolean) -> Unit, ) { BoxWithConstraints(Modifier.fillMaxSize()) { val screenWidthDp = maxWidth.value.toInt() @@ -1764,6 +1801,31 @@ private fun ProfileScreen( } } HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Pokazuj postęp aktywnego kursu", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = TppTheme.colors.ink, + ) + Text( + if (state.routeProgressNotificationEnabled) "Powiadomienie pokazuje dystans do celu." else "Powiadomienie zostaje minimalne: Kurs aktywny.", + style = MaterialTheme.typography.bodySmall, + color = TppTheme.colors.muted, + ) + } + Switch( + checked = state.routeProgressNotificationEnabled, + onCheckedChange = onRouteProgressNotificationChanged, + enabled = !state.loading, + ) + } + HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -1864,6 +1926,163 @@ private fun routeStatusColor(status: String): Color { } } +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@Composable +private fun RouteStageScreen( + state: DriverUiState, + title: String, + stage: String, + weightLabel: String, + submitLabel: String, + onBack: () -> Unit, + onWeightChange: (String) -> Unit, + onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, + onPhoto: (RoutePhotoDto) -> Unit, + onDeleteUpload: (PhotoUploadEntity) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, + onSubmit: () -> Unit, +) { + val context = LocalContext.current + var cameraUri by remember { mutableStateOf(null) } + var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } + val route = state.selectedRoute + val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty() + val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } + val canSubmit = canSubmitRouteStageForm( + weightText = state.routeStageWeightText, + serverPhotoCount = stagePhotos.size, + localUploadCount = stageUploads.size, + ) + + 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), + ) + ) { + 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 -> + if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata()) + } + + BoxWithConstraints(Modifier.fillMaxSize()) { + val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp + Scaffold( + topBar = { DetailHeader(onBack, title = title) }, + containerColor = TppTheme.colors.surface, + ) { padding -> + LazyColumn( + Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + item { + OutlinedTextField( + value = state.routeStageWeightText, + onValueChange = onWeightChange, + label = { Text(weightLabel) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (state.allowGalleryUploads) { + CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { + pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + } + CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) { + 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 { + PhotoGrid( + photos = stagePhotos, + uploads = stageUploads, + deletingPhotoIds = state.deletingPhotoIds, + imageAuthHeader = state.imageAuthHeader, + onPhoto = onPhoto, + onDeletePhoto = {}, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, + ) + } + item { + Button( + onClick = onSubmit, + enabled = canSubmit, + modifier = Modifier.fillMaxWidth().height(64.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.medium, + ) { + Text(submitLabel, color = Color.White, fontWeight = FontWeight.Bold) + } + } + item { ErrorText(state.error) } + } + } + } + + if (showPreciseLocationPermissionDialog) { + AlertDialog( + onDismissRequest = { showPreciseLocationPermissionDialog = false }, + title = { Text("Brak dokładnej lokalizacji", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, + text = { + Text( + "Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.", + color = TppTheme.colors.muted, + ) + }, + confirmButton = { + TextButton( + onClick = { + showPreciseLocationPermissionDialog = false + openAppSettings(context) + }, + ) { + Text("Przejdź do ustawień", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + TextButton(onClick = { showPreciseLocationPermissionDialog = false }) { + Text("Anuluj", color = TppTheme.colors.muted) + } + }, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable private fun DetailScreen( @@ -1875,11 +2094,11 @@ private fun DetailScreen( onDeleteUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit, onRefresh: () -> Unit, - onCompleteRoute: () -> Unit, + onStartRoute: () -> Unit, + onFinishRoute: () -> Unit, ) { val context = LocalContext.current var cameraUri by remember { mutableStateOf(null) } - var showCompleteDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> @@ -1933,19 +2152,18 @@ private fun DetailScreen( item { ManifestSection(route, selectedDate = state.selectedDate, onNavigate = { openNavigation(context, it) }) } item { OfflineStaleBanner(state) } item { - RouteCompletionSection( + RouteLifecycleSection( route = route, selectedDate = state.selectedDate, - allowRouteCompletion = state.allowRouteCompletion, - isOnline = state.isOnline, - completing = state.completingRoute, feedback = state.feedback, - onComplete = { showCompleteDialog = true }, + routeActions = state.routeActions, + onStart = onStartRoute, + onFinish = onFinishRoute, ) } item { CargoDocumentationSection( - photos = route.photos, + route = route, uploads = state.photoUploads, deletingPhotoIds = state.deletingPhotoIds, imageAuthHeader = state.imageAuthHeader, @@ -1986,33 +2204,6 @@ private fun DetailScreen( } } - if (showCompleteDialog && route != null) { - AlertDialog( - onDismissRequest = { if (!state.completingRoute) showCompleteDialog = false }, - title = { Text("Zakończyć kurs?", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) }, - text = { Text("Status kursu zostanie ustawiony jako zakończony.", color = TppTheme.colors.muted) }, - confirmButton = { - TextButton( - enabled = !state.completingRoute, - onClick = { - showCompleteDialog = false - onCompleteRoute() - }, - ) { - Text("Zakończ kurs", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) - } - }, - dismissButton = { - TextButton( - enabled = !state.completingRoute, - onClick = { showCompleteDialog = false }, - ) { - Text("Anuluj", color = TppTheme.colors.muted) - } - }, - ) - } - if (showPreciseLocationPermissionDialog) { AlertDialog( onDismissRequest = { showPreciseLocationPermissionDialog = false }, @@ -2067,75 +2258,101 @@ private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy" } @Composable -private fun RouteCompletionSection( +private fun RouteLifecycleSection( route: DriverRouteDto, selectedDate: String, - allowRouteCompletion: Boolean, - isOnline: Boolean, - completing: Boolean, feedback: String?, - onComplete: () -> Unit, + routeActions: List, + onStart: () -> Unit, + onFinish: () -> Unit, ) { - if (!allowRouteCompletion) return + val failedConflict = routeActions.firstOrNull { it.status == "FAILED_CONFLICT" } + val pendingAction = routeActions.firstOrNull { it.status in setOf("PENDING", "WAITING_FOR_PHOTOS", "SYNCING", "FAILED_RETRYABLE") } + val canStart = canStartRouteFromDriverApp(route, selectedDate) + val canFinish = canFinishRouteFromDriverApp(route, selectedDate) - if (route.status == "ZAKOŃCZONA") { + if (failedConflict == null && pendingAction == null && feedback.isNullOrBlank() && !canStart && !canFinish) return + + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (!feedback.isNullOrBlank()) { - Card( - colors = CardDefaults.cardColors(containerColor = TppTheme.colors.successContainer), - border = BorderStroke(1.dp, TppTheme.colors.successOutline), - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - Modifier.fillMaxWidth().padding(14.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = TppTheme.colors.forest) - Text(feedback, color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) - } - } + RouteStateMessage( + text = feedback, + icon = Icons.Outlined.CheckCircle, + container = TppTheme.colors.successContainer, + outline = TppTheme.colors.successOutline, + color = TppTheme.colors.forest, + ) } - return - } - if (!canCompleteRouteFromDriverApp(route, selectedDate)) return + if (failedConflict != null) { + RouteStateMessage( + text = failedConflict.lastError ?: "Synchronizacja kursu jest w konflikcie z backendem. Dane lokalne zostały zachowane.", + icon = Icons.Outlined.Info, + container = TppTheme.colors.warningContainer, + outline = TppTheme.colors.warningOutline, + color = TppTheme.colors.error, + ) + } else if (pendingAction != null) { + RouteStateMessage( + text = when (pendingAction.status) { + "WAITING_FOR_PHOTOS" -> "Czekam na wysłanie zdjęć etapu." + "SYNCING" -> "Synchronizuję zmianę statusu kursu." + "FAILED_RETRYABLE" -> "Synchronizacja nie powiodła się, aplikacja spróbuje ponownie." + else -> "Zmiana statusu kursu czeka na synchronizację." + }, + icon = Icons.Outlined.CloudUpload, + container = TppTheme.colors.panel, + outline = TppTheme.colors.outline, + color = TppTheme.colors.muted, + ) + } + + if (!canStart && !canFinish) return@Column - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Button( - onClick = onComplete, - enabled = isOnline && !completing, + onClick = if (canStart) onStart else onFinish, modifier = Modifier.fillMaxWidth().height(64.dp), colors = ButtonDefaults.buttonColors( containerColor = TppTheme.colors.forest, - disabledContainerColor = TppTheme.colors.outline, ), shape = MaterialTheme.shapes.medium, ) { - if (completing) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = Color.White, - strokeWidth = 2.dp, - ) - } else { - Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White) - } + Icon( + if (canStart) Icons.Outlined.LocalShipping else Icons.Outlined.CheckCircle, + contentDescription = null, + tint = Color.White, + ) Spacer(Modifier.width(10.dp)) Text( - if (completing) "Kończę kurs..." else "Zakończ kurs", + if (canStart) "Rozpocznij 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 = TppTheme.colors.muted, - fontFamily = FontFamily.Monospace, - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(horizontal = 4.dp), - ) + } +} + +@Composable +private fun RouteStateMessage( + text: String, + icon: ImageVector, + container: Color, + outline: Color, + color: Color, +) { + Card( + colors = CardDefaults.cardColors(containerColor = container), + border = BorderStroke(1.dp, outline), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = color) + Text(text, color = color, fontWeight = FontWeight.Bold) } } } @@ -2320,7 +2537,7 @@ private fun RouteFact(label: String, value: String, modifier: Modifier = Modifie @Composable private fun CargoDocumentationSection( - photos: List, + route: DriverRouteDto, uploads: List, deletingPhotoIds: Set, imageAuthHeader: String?, @@ -2333,28 +2550,94 @@ private fun CargoDocumentationSection( onCamera: () -> Unit, onGallery: () -> Unit, ) { + val loadingPhotos = routePhotosForStage(route.photos, "loading") + val unloadingPhotos = routePhotosForStage(route.photos, "unloading") + val otherPhotos = routePhotosForStage(route.photos, "other") + val loadingUploads = uploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" } + val unloadingUploads = uploads.filter { normalizedRoutePhotoStage(it.stage) == "unloading" } + val otherUploads = uploads.filter { normalizedRoutePhotoStage(it.stage) == "other" } + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column { Text("Dokumentacja ładunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = TppTheme.colors.ink) Spacer(Modifier.height(8.dp)) Box(Modifier.width(116.dp).height(3.dp).background(TppTheme.colors.containerGreen)) } - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (canManagePhotos) { - if (allowGalleryUploads) { - CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen, onGallery) - } - CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest, onCamera) - } else { - Box(Modifier.fillMaxWidth().background(TppTheme.colors.panel, RoundedCornerShape(4.dp)).padding(14.dp)) { - Text("Zdjęcia można dodawać tylko dla dzisiejszych kursów.", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace) - } - } + RouteStageDocumentationBlock( + title = "Załadunek", + weight = route.loadingWeight, + photos = loadingPhotos, + uploads = loadingUploads, + deletingPhotoIds = deletingPhotoIds, + imageAuthHeader = imageAuthHeader, + onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, + ) + RouteStageDocumentationBlock( + title = "Rozładunek", + weight = route.unloadingWeight, + photos = unloadingPhotos, + uploads = unloadingUploads, + deletingPhotoIds = deletingPhotoIds, + imageAuthHeader = imageAuthHeader, + onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, + ) + if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) { + RouteStageDocumentationBlock( + title = "Pozostałe", + weight = null, + photos = otherPhotos, + uploads = otherUploads, + deletingPhotoIds = deletingPhotoIds, + imageAuthHeader = imageAuthHeader, + onPhoto = onPhoto, + onDeletePhoto = onDeletePhoto, + onDeleteUpload = onDeleteUpload, + onRetryUpload = onRetryUpload, + ) + } + } +} + +@Composable +private fun RouteStageDocumentationBlock( + title: String, + weight: Double?, + photos: List, + uploads: List, + deletingPhotoIds: Set, + imageAuthHeader: String?, + onPhoto: (RoutePhotoDto) -> Unit, + onDeletePhoto: (RoutePhotoDto) -> Unit, + onDeleteUpload: (PhotoUploadEntity) -> Unit, + onRetryUpload: (PhotoUploadEntity) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Text( + weight?.let { "Waga: ${formatRouteWeight(it)}" } ?: "Waga: -", + color = TppTheme.colors.muted, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) } PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onDeleteUpload, onRetryUpload) } } +private fun formatRouteWeight(weight: Double): String = + if (weight % 1.0 == 0.0) "${weight.toInt()} kg" else String.format(Locale("pl", "PL"), "%.2f kg", weight) + @Composable private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) { Button( 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 d0d5412..3eb6108 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -24,6 +24,56 @@ fun canCompleteRouteFromDriverApp( canManageRoutePhotos(selectedDate, today) && route.status in setOf("ZAPLANOWANA", "W TRAKCIE") +fun canStartRouteFromDriverApp( + route: DriverRouteDto, + selectedDate: String, + today: LocalDate = LocalDate.now(), +): Boolean = + canManageRoutePhotos(selectedDate, today) + && route.status == "ZAPLANOWANA" + +fun canFinishRouteFromDriverApp( + route: DriverRouteDto, + selectedDate: String, + today: LocalDate = LocalDate.now(), +): Boolean = + canManageRoutePhotos(selectedDate, today) + && route.status == "W TRAKCIE" + +fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean { + val normalizedWeight = weightText.trim().replace(',', '.').toDoubleOrNull() ?: return false + return normalizedWeight > 0.0 && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) > 0 +} + +fun routePhotosForStage(photos: List, stage: String): List { + val normalized = normalizedRoutePhotoStage(stage) + return photos.filter { normalizedRoutePhotoStage(it.stage) == normalized } +} + +fun normalizedRoutePhotoStage(stage: String?): String = + when (stage) { + "loading", "unloading" -> stage + else -> "other" + } + +fun activeRouteUploadIntervalSeconds( + isMoving: Boolean, + isUrbanOrTurning: Boolean = false, + isNearDestination: Boolean = false, + isHighwayStable: Boolean = false, + isLikelyOffRoute: Boolean = false, +): Int = + when { + isLikelyOffRoute -> 3 + !isMoving -> 45 + isUrbanOrTurning || isNearDestination -> 5 + isHighwayStable -> 20 + else -> 20 + } + +fun activeRouteNotificationTarget(arrived: Boolean): DriverScreen = + if (arrived) DriverScreen.FinishRoute else DriverScreen.Detail + fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean = viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate) @@ -138,6 +188,7 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? { clientRequestId = upload.clientRequestId, contentSha256 = upload.contentSha256, source = upload.source, + stage = normalizedRoutePhotoStage(upload.stage), mimeType = upload.mimeType, size = upload.size, url = File(upload.localPath).toURI().toString(), 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 2e2aee1..526c940 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.Instant import pl.firmatpp.kierowca.data.AppPreferencesStore import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorKind @@ -27,13 +28,16 @@ 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.data.upload.RouteActionEntity +import pl.firmatpp.kierowca.data.upload.RouteActionOutbox import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.sync.DriverLiveSyncClient import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncWorker +import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService import pl.firmatpp.kierowca.ui.theme.AppThemeMode -enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest } +enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, StartRoute, FinishRoute, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest } data class DriverUiState( val screen: DriverScreen = DriverScreen.Initializing, @@ -52,9 +56,12 @@ data class DriverUiState( val allowRouteCompletion: Boolean = false, val requirePreciseLocationForPhotos: Boolean = false, val notifyNewRoutes: Boolean = false, + val routeProgressNotificationEnabled: Boolean = true, val notificationPermissionDenied: Boolean = false, val selectedRoute: DriverRouteDto? = null, val selectedPhoto: RoutePhotoDto? = null, + val routeStageWeightText: String = "", + val routeActions: List = emptyList(), val photoUploads: List = emptyList(), val queuedPhotoUploads: List = emptyList(), val dispatchSheetReminder: DispatchSheetReminderDto? = null, @@ -108,6 +115,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 routeActionOutbox = RouteActionOutbox(application) private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application) private val _state = MutableStateFlow(DriverUiState(loading = true)) private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() @@ -117,6 +125,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } }, ) private var photoUploadsJob: Job? = null + private var routeActionsJob: Job? = null private var dispatchSheetUploadsJob: Job? = null private var pushTokenRegisteredForDriverId: String? = null val state: StateFlow = _state @@ -127,6 +136,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(themeMode = themeMode) } } } + viewModelScope.launch { + appPreferencesStore.routeProgressNotificationEnabled.collect { enabled -> + _state.update { it.copy(routeProgressNotificationEnabled = enabled) } + } + } viewModelScope.launch { photoUploadOutbox.observeQueuedUploads().collect { uploads -> _state.update { it.copy(queuedPhotoUploads = uploads) } @@ -268,14 +282,21 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } - fun openRoute(routeId: String) = runLoading("open_route", mapOf("route_id" to routeId)) { + fun openRoute(routeId: String) = openRouteWithTarget(routeId, DriverScreen.Detail) + + private fun openRouteWithTarget(routeId: String, targetScreen: DriverScreen) = runLoading("open_route", mapOf("route_id" to routeId)) { val cached = syncRepository.route(routeId) val response = cached.value photoUploadOutbox.discardConfirmedServerPhotos(response.route) _state.update { it.copy( - screen = DriverScreen.Detail, + screen = targetScreen, selectedRoute = response.route, + routeStageWeightText = when (targetScreen) { + DriverScreen.StartRoute -> response.route.loadingWeight?.toString().orEmpty() + DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty() + else -> it.routeStageWeightText + }, feedback = null, isStale = cached.stale || !it.isOnline, lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, @@ -283,6 +304,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) } observePhotoUploads(routeId) + observeRouteActions(routeId) } fun refreshSelectedRoute() { @@ -355,6 +377,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + fun setRouteProgressNotificationEnabled(enabled: Boolean) { + _state.update { it.copy(routeProgressNotificationEnabled = enabled) } + viewModelScope.launch { + runCatching { appPreferencesStore.setRouteProgressNotificationEnabled(enabled) } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + } + } + fun openPhotoQueue() { _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } } @@ -481,17 +511,143 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } - fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) { + fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other") { val route = _state.value.selectedRoute ?: return viewModelScope.launch { _state.update { it.copy(error = null) } - runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) } + runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) } .onFailure { throwable -> _state.update { it.withApiError(throwable) } } } } + fun openStartRoute() { + val route = _state.value.selectedRoute ?: return + _state.update { + it.copy( + screen = DriverScreen.StartRoute, + routeStageWeightText = route.loadingWeight?.toString().orEmpty(), + feedback = null, + error = null, + ) + } + } + + fun openFinishRoute() { + val route = _state.value.selectedRoute ?: return + _state.update { + it.copy( + screen = DriverScreen.FinishRoute, + routeStageWeightText = route.unloadingWeight?.toString().orEmpty(), + feedback = null, + error = null, + ) + } + } + + fun updateRouteStageWeight(value: String) { + val normalized = value.filter { it.isDigit() || it == ',' || it == '.' }.take(8) + _state.update { it.copy(routeStageWeightText = normalized) } + } + + fun submitStartRoute() { + submitRouteStage(stage = "loading") + } + + fun submitFinishRoute() { + submitRouteStage(stage = "unloading") + } + + private fun submitRouteStage(stage: String) { + val snapshot = _state.value + val route = snapshot.selectedRoute ?: return + val driver = snapshot.driver + val weight = snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() + val stagePhotos = routePhotosForStage(route.photos, stage) + val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } + + if ( + weight == null || + !canSubmitRouteStageForm( + weightText = snapshot.routeStageWeightText, + serverPhotoCount = stagePhotos.size, + localUploadCount = stageUploads.size, + ) + ) { + _state.update { it.copy(error = "Podaj wagę i dodaj co najmniej jedno zdjęcie.") } + return + } + + val photoClientRequestIds = (stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId }) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + + if (photoClientRequestIds.isEmpty()) { + _state.update { it.copy(error = "Zdjęcie musi mieć lokalny identyfikator synchronizacji.") } + return + } + + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null, feedback = null) } + runCatching { + val updatedRoute = when (stage) { + "loading" -> { + routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds) + route.copy( + status = "W TRAKCIE", + loadingWeight = weight, + trackingStatus = "active", + ) + } + "unloading" -> { + routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds) + route.copy( + status = "ZAKOŃCZONA", + unloadingWeight = weight, + trackingStatus = "finished", + completedAt = Instant.now().toString(), + ) + } + else -> route + } + + syncRepository.cacheConfirmedRoute(updatedRoute, snapshot.selectedDate) + _state.update { + it.copy( + screen = DriverScreen.Detail, + selectedRoute = updatedRoute, + routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, + routeStageWeightText = "", + feedback = if (stage == "loading") { + "Kurs rozpoczęty lokalnie. Synchronizacja pójdzie w tle." + } else { + "Kurs zakończony lokalnie. Synchronizacja pójdzie w tle." + }, + error = null, + ) + } + + if (stage == "loading" && driver != null) { + ActiveRouteTrackingService.start( + context = getApplication(), + route = updatedRoute, + driver = driver, + progressEnabled = snapshot.routeProgressNotificationEnabled, + ) + } + if (stage == "unloading") { + ActiveRouteTrackingService.stop(getApplication()) + } + }.onFailure { throwable -> + reportHandledException("submit_route_stage", throwable, mapOf("route_id" to route.id, "stage" to stage)) + _state.update { it.withApiError(throwable) } + } + _state.update { it.copy(loading = false) } + } + } + fun uploadDispatchSheetPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) { val reminder = _state.value.dispatchSheetReminder ?: return val workDate = reminder.workDate ?: return @@ -604,9 +760,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) } } - fun openRouteFromNotification(routeId: String?) { + fun openRouteFromNotification(routeId: String?, target: String? = null) { if (routeId.isNullOrBlank()) return - openRoute(routeId) + val targetScreen = if (target == "finish") DriverScreen.FinishRoute else DriverScreen.Detail + openRouteWithTarget(routeId, targetScreen) } fun back() { @@ -614,12 +771,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) when (it.screen) { DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) + DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) + DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes) DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) DriverScreen.Detail -> { photoUploadsJob?.cancel() - it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null) + routeActionsJob?.cancel() + it.copy(screen = DriverScreen.Routes, selectedRoute = null, routeActions = emptyList(), photoUploads = emptyList(), feedback = null) } DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes) DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone) @@ -630,6 +790,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun logout() = runLoading("logout") { photoUploadsJob?.cancel() + routeActionsJob?.cancel() dispatchSheetUploadsJob?.cancel() liveSyncClient.stop() repository.logout() @@ -664,6 +825,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + private fun observeRouteActions(routeId: String) { + routeActionsJob?.cancel() + routeActionsJob = viewModelScope.launch { + routeActionOutbox.observeUnresolvedForRoute(routeId).collect { actions -> + _state.update { it.copy(routeActions = actions) } + } + } + } + private fun observeDispatchSheetUploads(workDate: String?) { dispatchSheetUploadsJob?.cancel() if (workDate.isNullOrBlank()) { @@ -785,6 +955,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) override fun onCleared() { liveSyncClient.close() + photoUploadsJob?.cancel() + routeActionsJob?.cancel() dispatchSheetUploadsJob?.cancel() super.onCleared() } 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 e9c6b95..86efd84 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -7,6 +7,7 @@ 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.model.RoutePhotoDto import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity class DriverUiRulesTest { @@ -246,6 +247,44 @@ class DriverUiRulesTest { assertEquals("Popraw", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "uploaded"), hasQueuedUpload = false)) } + @Test + fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() { + assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0)) + assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1)) + assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0)) + assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0)) + assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0)) + } + + @Test + fun filtersRoutePhotosByStageWithOtherFallback() { + val photos = listOf( + photo("1", stage = "loading"), + photo("2", stage = "unloading"), + photo("3", stage = null), + ) + + assertEquals(listOf("1"), routePhotosForStage(photos, "loading").map { it.id }) + assertEquals(listOf("2"), routePhotosForStage(photos, "unloading").map { it.id }) + assertEquals(listOf("3"), routePhotosForStage(photos, "other").map { it.id }) + } + + @Test + fun choosesAdaptiveGpsUploadIntervalForActiveRoute() { + assertEquals(45, activeRouteUploadIntervalSeconds(isMoving = false)) + assertEquals(20, activeRouteUploadIntervalSeconds(isMoving = true)) + assertEquals(5, activeRouteUploadIntervalSeconds(isMoving = true, isUrbanOrTurning = true)) + assertEquals(5, activeRouteUploadIntervalSeconds(isMoving = true, isNearDestination = true)) + assertEquals(20, activeRouteUploadIntervalSeconds(isMoving = true, isHighwayStable = true)) + assertEquals(3, activeRouteUploadIntervalSeconds(isMoving = true, isLikelyOffRoute = true)) + } + + @Test + fun notificationTapTargetsFinishScreenOnlyAfterArrival() { + assertEquals(DriverScreen.Detail, activeRouteNotificationTarget(arrived = false)) + assertEquals(DriverScreen.FinishRoute, activeRouteNotificationTarget(arrived = true)) + } + private fun route(status: String): DriverRouteDto = DriverRouteDto( id = "1", @@ -279,6 +318,24 @@ class DriverUiRulesTest { serverPhotoId = serverPhotoId, ) + private fun photo(id: String, stage: String?): RoutePhotoDto = + RoutePhotoDto( + id = id, + routeId = "10", + clientRequestId = null, + contentSha256 = null, + source = "camera", + stage = stage ?: "other", + mimeType = "image/jpeg", + size = 1200, + url = "https://example.test/$id.jpg", + takenAt = null, + latitude = null, + longitude = null, + locationAccuracyMeters = null, + createdAt = null, + ) + private fun dispatchReminder(status: String, dueToday: Boolean = true): DispatchSheetReminderDto = DispatchSheetReminderDto( enabled = dueToday,