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,