Implement driver route lifecycle flow

This commit is contained in:
admin
2026-07-07 12:29:12 +02:00
parent 1834763f19
commit 13159c576e
22 changed files with 1687 additions and 110 deletions
+7
View File
@@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application <application
android:allowBackup="false" android:allowBackup="false"
@@ -40,5 +42,10 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".tracking.ActiveRouteTrackingService"
android:exported="false"
android:foregroundServiceType="location" />
</application> </application>
</manifest> </manifest>
@@ -14,11 +14,13 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null) private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationRouteTarget by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null) private var notificationLeaveRequestId by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
setContent { setContent {
val viewModel: DriverViewModel = viewModel() val viewModel: DriverViewModel = viewModel()
@@ -28,6 +30,7 @@ class MainActivity : ComponentActivity() {
DriverApp( DriverApp(
viewModel = viewModel, viewModel = viewModel,
initialRouteId = notificationRouteId, initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget,
initialLeaveRequestId = notificationLeaveRequestId, initialLeaveRequestId = notificationLeaveRequestId,
) )
} }
@@ -38,11 +41,15 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
} }
companion object { companion object {
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID" 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 EXTRA_LEAVE_REQUEST_ID = "pl.firmatpp.kierowca.EXTRA_LEAVE_REQUEST_ID"
const val ROUTE_TARGET_DETAIL = "detail"
const val ROUTE_TARGET_FINISH = "finish"
} }
} }
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
@@ -18,13 +19,23 @@ class AppPreferencesStore(
constructor(context: Context) : this(context.driverAppPreferencesDataStore) constructor(context: Context) : this(context.driverAppPreferencesDataStore)
private val themeModeKey = stringPreferencesKey("theme_mode") private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
suspend fun setThemeMode(themeMode: AppThemeMode) { suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue preferences[themeModeKey] = themeMode.storedValue
} }
} }
suspend fun setRouteProgressNotificationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeProgressNotificationEnabledKey] = enabled
}
}
} }
@@ -19,6 +19,7 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto 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.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse 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.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody 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.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
@@ -69,6 +73,15 @@ class DriverRepository(
suspend fun completeRoute(routeId: String) = suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId) 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 = suspend fun updateNotificationPreferences(notifyNewRoutes: Boolean): NotificationPreferencesDto =
api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes)) api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes))
@@ -95,6 +108,7 @@ class DriverRepository(
routeId: String, routeId: String,
uri: Uri, uri: Uri,
source: String, source: String,
stage: String = "other",
metadata: PhotoUploadMetadata = PhotoUploadMetadata(), metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
): PhotoUploadResponse { ): PhotoUploadResponse {
val resolver = context.contentResolver val resolver = context.contentResolver
@@ -115,6 +129,7 @@ class DriverRepository(
clientRequestId.toPlainTextBody(), clientRequestId.toPlainTextBody(),
contentSha256.toPlainTextBody(), contentSha256.toPlainTextBody(),
sourceBody, sourceBody,
stage.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -141,6 +156,7 @@ class DriverRepository(
upload.clientRequestId.toPlainTextBody(), upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(), upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(), upload.source.toPlainTextBody(),
upload.stage.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -9,6 +9,7 @@ import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse 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.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse 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.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody 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.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.model.VerifyOtpResponse import pl.firmatpp.kierowca.data.model.VerifyOtpResponse
@@ -102,6 +107,27 @@ interface MobileDriverApi {
@Path("routeId") routeId: String, @Path("routeId") routeId: String,
): CompleteRouteResponse ): 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") @GET("mobile/driver/sync-state")
suspend fun syncState( suspend fun syncState(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -142,6 +168,7 @@ interface MobileDriverApi {
@Part("clientRequestId") clientRequestId: RequestBody, @Part("clientRequestId") clientRequestId: RequestBody,
@Part("contentSha256") contentSha256: RequestBody, @Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody, @Part("source") source: RequestBody,
@Part("stage") stage: RequestBody?,
@Part("takenAt") takenAt: RequestBody?, @Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?, @Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?, @Part("longitude") longitude: RequestBody?,
@@ -232,6 +232,10 @@ data class DriverRouteDto(
val contractCode: String?, val contractCode: String?,
val relationLabel: String, val relationLabel: String,
val status: String, val status: String,
val loadingWeight: Double? = null,
val unloadingWeight: Double? = null,
val trackingStatus: String? = null,
val arrivedAt: String? = null,
val distanceKm: Double?, val distanceKm: Double?,
val notes: String?, val notes: String?,
val truck: TruckDto?, val truck: TruckDto?,
@@ -240,6 +244,13 @@ data class DriverRouteDto(
val originNavigation: NavigationPointDto? = null, val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null,
val photos: List<RoutePhotoDto> = emptyList(), val photos: List<RoutePhotoDto> = emptyList(),
val photosByStage: RoutePhotosByStageDto? = null,
)
data class RoutePhotosByStageDto(
val loading: List<RoutePhotoDto> = emptyList(),
val unloading: List<RoutePhotoDto> = emptyList(),
val other: List<RoutePhotoDto> = emptyList(),
) )
data class TruckDto( data class TruckDto(
@@ -262,6 +273,7 @@ data class RoutePhotoDto(
val clientRequestId: String? = null, val clientRequestId: String? = null,
val contentSha256: String? = null, val contentSha256: String? = null,
val source: String, val source: String,
val stage: String = "other",
val mimeType: String?, val mimeType: String?,
val size: Long, val size: Long,
val url: String, val url: String,
@@ -286,6 +298,55 @@ data class PhotoUploadReceiptDto(
val storedAt: String?, val storedAt: String?,
) )
data class StartRouteBody(
val clientActionId: String,
val loadingWeight: Double,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
data class FinishRouteBody(
val clientActionId: String,
val unloadingWeight: Double,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
data class RouteActionResponse(
val ok: Boolean,
val alreadyProcessed: Boolean,
val route: DriverRouteDto,
)
data class RoutePointBatchBody(
val clientBatchId: String,
val points: List<RoutePointDto>,
)
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( data class DispatchSheetUploadResponse(
val photo: DispatchSheetPhotoDto, val photo: DispatchSheetPhotoDto,
val reminder: DispatchSheetReminderDto, val reminder: DispatchSheetReminderDto,
@@ -15,16 +15,20 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
entities = [ entities = [
PhotoUploadEntity::class, PhotoUploadEntity::class,
DispatchSheetUploadEntity::class, DispatchSheetUploadEntity::class,
RouteActionEntity::class,
RoutePointEntity::class,
DriverBootstrapCacheEntity::class, DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 3, version = 5,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao abstract fun photoUploadDao(): PhotoUploadDao
abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao
abstract fun routeActionDao(): RouteActionDao
abstract fun routePointDao(): RoutePointDao
abstract fun driverCacheDao(): DriverCacheDao abstract fun driverCacheDao(): DriverCacheDao
companion object { 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 = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
DriverDatabase::class.java, DriverDatabase::class.java,
"driver-local-outbox.db", "driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3).build().also { instance = it } ).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it }
} }
} }
} }
@@ -16,6 +16,7 @@ data class PhotoUploadEntity(
val routeId: String, val routeId: String,
val localPath: String, val localPath: String,
val source: String, val source: String,
val stage: String = "other",
val takenAt: String?, val takenAt: String?,
val latitude: Double?, val latitude: Double?,
val longitude: Double?, val longitude: Double?,
@@ -28,7 +28,7 @@ class PhotoUploadOutbox(
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> = fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads() 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 clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) { val extension = when (mimeType) {
@@ -44,6 +44,7 @@ class PhotoUploadOutbox(
routeId = routeId, routeId = routeId,
localPath = localFile.absolutePath, localPath = localFile.absolutePath,
source = source, source = source,
stage = stage,
takenAt = metadata.takenAt, takenAt = metadata.takenAt,
latitude = metadata.latitude, latitude = metadata.latitude,
longitude = metadata.longitude, longitude = metadata.longitude,
@@ -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<List<RouteActionEntity>>
@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(),
)
}
@@ -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(),
)
@@ -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<List<RouteActionEntity>> =
dao.observeUnresolvedForRoute(routeId)
suspend fun enqueueStart(routeId: String, loadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds)
private suspend fun enqueue(
action: String,
routeId: String,
weight: Double,
photoClientRequestIds: List<String>,
): 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<RouteActionWorker>()
.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,
)
}
}
@@ -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<String>): 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<String> =
runCatching {
gson.fromJson(action.photoClientRequestIdsJson, Array<String>::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"
}
}
@@ -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<RoutePointEntity>
@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<String>,
status: String,
lastError: String?,
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
}
@@ -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(),
)
@@ -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<RoutePointWorker>()
.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,
)
}
}
@@ -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"
}
}
@@ -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<Int?, Boolean> {
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)
}
}
}
@@ -147,13 +147,19 @@ import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus 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.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.AppThemeMode
import pl.firmatpp.kierowca.ui.theme.TppTheme import pl.firmatpp.kierowca.ui.theme.TppTheme
@Composable @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 state by viewModel.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.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, enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
onCode = viewModel::updateOtpCode, onCode = viewModel::updateOtpCode,
) )
LaunchedEffect(initialRouteId) { LaunchedEffect(initialRouteId, initialRouteTarget) {
viewModel.openRouteFromNotification(initialRouteId) viewModel.openRouteFromNotification(initialRouteId, initialRouteTarget)
} }
LaunchedEffect(initialLeaveRequestId, state.leaveRequestsConfig) { LaunchedEffect(initialLeaveRequestId, state.leaveRequestsConfig) {
if (!initialLeaveRequestId.isNullOrBlank() && DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) { if (!initialLeaveRequestId.isNullOrBlank() && DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) {
@@ -241,17 +247,47 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initia
}, },
onOpenNotificationSettings = { openAppNotificationSettings(context) }, onOpenNotificationSettings = { openAppNotificationSettings(context) },
onThemeModeChanged = viewModel::setThemeMode, onThemeModeChanged = viewModel::setThemeMode,
onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled,
) )
DriverScreen.Detail -> DetailScreen( DriverScreen.Detail -> DetailScreen(
state, state,
viewModel::back, viewModel::back,
viewModel::uploadPhoto, { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata) },
viewModel::openPhoto, viewModel::openPhoto,
viewModel::deletePhoto, viewModel::deletePhoto,
viewModel::deleteConfirmedUpload, viewModel::deleteConfirmedUpload,
viewModel::retryPhotoUpload, viewModel::retryPhotoUpload,
viewModel::refreshSelectedRoute, 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.Photo -> PhotoScreen(state, viewModel::back)
DriverScreen.PhotoQueue -> PhotoQueueScreen( DriverScreen.PhotoQueue -> PhotoQueueScreen(
@@ -1671,6 +1707,7 @@ private fun ProfileScreen(
onNewRouteNotificationsChanged: (Boolean) -> Unit, onNewRouteNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onThemeModeChanged: (AppThemeMode) -> Unit, onThemeModeChanged: (AppThemeMode) -> Unit,
onRouteProgressNotificationChanged: (Boolean) -> Unit,
) { ) {
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
val screenWidthDp = maxWidth.value.toInt() val screenWidthDp = maxWidth.value.toInt()
@@ -1764,6 +1801,31 @@ private fun ProfileScreen(
} }
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) 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( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp), 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<Uri?>(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) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable @Composable
private fun DetailScreen( private fun DetailScreen(
@@ -1875,11 +2094,11 @@ private fun DetailScreen(
onDeleteUpload: (PhotoUploadEntity) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit, onRetryUpload: (PhotoUploadEntity) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onCompleteRoute: () -> Unit, onStartRoute: () -> Unit,
onFinishRoute: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showCompleteDialog by remember { mutableStateOf(false) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -1933,19 +2152,18 @@ private fun DetailScreen(
item { ManifestSection(route, selectedDate = state.selectedDate, onNavigate = { openNavigation(context, it) }) } item { ManifestSection(route, selectedDate = state.selectedDate, onNavigate = { openNavigation(context, it) }) }
item { OfflineStaleBanner(state) } item { OfflineStaleBanner(state) }
item { item {
RouteCompletionSection( RouteLifecycleSection(
route = route, route = route,
selectedDate = state.selectedDate, selectedDate = state.selectedDate,
allowRouteCompletion = state.allowRouteCompletion,
isOnline = state.isOnline,
completing = state.completingRoute,
feedback = state.feedback, feedback = state.feedback,
onComplete = { showCompleteDialog = true }, routeActions = state.routeActions,
onStart = onStartRoute,
onFinish = onFinishRoute,
) )
} }
item { item {
CargoDocumentationSection( CargoDocumentationSection(
photos = route.photos, route = route,
uploads = state.photoUploads, uploads = state.photoUploads,
deletingPhotoIds = state.deletingPhotoIds, deletingPhotoIds = state.deletingPhotoIds,
imageAuthHeader = state.imageAuthHeader, 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) { if (showPreciseLocationPermissionDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showPreciseLocationPermissionDialog = false }, onDismissRequest = { showPreciseLocationPermissionDialog = false },
@@ -2067,75 +2258,101 @@ private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy"
} }
@Composable @Composable
private fun RouteCompletionSection( private fun RouteLifecycleSection(
route: DriverRouteDto, route: DriverRouteDto,
selectedDate: String, selectedDate: String,
allowRouteCompletion: Boolean,
isOnline: Boolean,
completing: Boolean,
feedback: String?, feedback: String?,
onComplete: () -> Unit, routeActions: List<RouteActionEntity>,
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()) { if (!feedback.isNullOrBlank()) {
Card( RouteStateMessage(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.successContainer), text = feedback,
border = BorderStroke(1.dp, TppTheme.colors.successOutline), icon = Icons.Outlined.CheckCircle,
shape = MaterialTheme.shapes.medium, container = TppTheme.colors.successContainer,
modifier = Modifier.fillMaxWidth(), outline = TppTheme.colors.successOutline,
) { color = TppTheme.colors.forest,
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)
}
}
} }
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( Button(
onClick = onComplete, onClick = if (canStart) onStart else onFinish,
enabled = isOnline && !completing,
modifier = Modifier.fillMaxWidth().height(64.dp), modifier = Modifier.fillMaxWidth().height(64.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = TppTheme.colors.forest, containerColor = TppTheme.colors.forest,
disabledContainerColor = TppTheme.colors.outline,
), ),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
) { ) {
if (completing) { Icon(
CircularProgressIndicator( if (canStart) Icons.Outlined.LocalShipping else Icons.Outlined.CheckCircle,
modifier = Modifier.size(20.dp), contentDescription = null,
color = Color.White, tint = Color.White,
strokeWidth = 2.dp, )
)
} else {
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White)
}
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text( Text(
if (completing) "Kończę kurs..." else "Zakończ kurs", if (canStart) "Rozpocznij kurs" else "Zakończ kurs",
color = Color.White, color = Color.White,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
if (!isOnline) { }
Text( }
"Kurs można zakończyć tylko po połączeniu z serwerem.",
color = TppTheme.colors.muted, @Composable
fontFamily = FontFamily.Monospace, private fun RouteStateMessage(
style = MaterialTheme.typography.labelMedium, text: String,
modifier = Modifier.padding(horizontal = 4.dp), 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 @Composable
private fun CargoDocumentationSection( private fun CargoDocumentationSection(
photos: List<RoutePhotoDto>, route: DriverRouteDto,
uploads: List<PhotoUploadEntity>, uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>, deletingPhotoIds: Set<String>,
imageAuthHeader: String?, imageAuthHeader: String?,
@@ -2333,28 +2550,94 @@ private fun CargoDocumentationSection(
onCamera: () -> Unit, onCamera: () -> Unit,
onGallery: () -> 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(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column { Column {
Text("Dokumentacja ładunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = TppTheme.colors.ink) Text("Dokumentacja ładunku", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = TppTheme.colors.ink)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Box(Modifier.width(116.dp).height(3.dp).background(TppTheme.colors.containerGreen)) Box(Modifier.width(116.dp).height(3.dp).background(TppTheme.colors.containerGreen))
} }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { RouteStageDocumentationBlock(
if (canManagePhotos) { title = "Załadunek",
if (allowGalleryUploads) { weight = route.loadingWeight,
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen, onGallery) photos = loadingPhotos,
} uploads = loadingUploads,
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest, onCamera) deletingPhotoIds = deletingPhotoIds,
} else { imageAuthHeader = imageAuthHeader,
Box(Modifier.fillMaxWidth().background(TppTheme.colors.panel, RoundedCornerShape(4.dp)).padding(14.dp)) { onPhoto = onPhoto,
Text("Zdjęcia można dodawać tylko dla dzisiejszych kursów.", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace) 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<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
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) 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 @Composable
private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) { private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) {
Button( Button(
@@ -24,6 +24,56 @@ fun canCompleteRouteFromDriverApp(
canManageRoutePhotos(selectedDate, today) canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE") && 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<RoutePhotoDto>, stage: String): List<RoutePhotoDto> {
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 = fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate) viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
@@ -138,6 +188,7 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
clientRequestId = upload.clientRequestId, clientRequestId = upload.clientRequestId,
contentSha256 = upload.contentSha256, contentSha256 = upload.contentSha256,
source = upload.source, source = upload.source,
stage = normalizedRoutePhotoStage(upload.stage),
mimeType = upload.mimeType, mimeType = upload.mimeType,
size = upload.size, size = upload.size,
url = File(upload.localPath).toURI().toString(), url = File(upload.localPath).toURI().toString(),
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import java.time.Instant
import pl.firmatpp.kierowca.data.AppPreferencesStore import pl.firmatpp.kierowca.data.AppPreferencesStore
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind 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.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox 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.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker import pl.firmatpp.kierowca.sync.DriverSyncWorker
import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService
import pl.firmatpp.kierowca.ui.theme.AppThemeMode 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( data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
@@ -52,9 +56,12 @@ data class DriverUiState(
val allowRouteCompletion: Boolean = false, val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false, val requirePreciseLocationForPhotos: Boolean = false,
val notifyNewRoutes: Boolean = false, val notifyNewRoutes: Boolean = false,
val routeProgressNotificationEnabled: Boolean = true,
val notificationPermissionDenied: Boolean = false, val notificationPermissionDenied: Boolean = false,
val selectedRoute: DriverRouteDto? = null, val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "",
val routeActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
@@ -108,6 +115,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val syncRepository = DriverSyncRepository(application, repository) private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application) private val photoUploadOutbox = PhotoUploadOutbox(application)
private val routeActionOutbox = RouteActionOutbox(application)
private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application) private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true)) private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
@@ -117,6 +125,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } }, onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } },
) )
private var photoUploadsJob: Job? = null private var photoUploadsJob: Job? = null
private var routeActionsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null private var pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
@@ -127,6 +136,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(themeMode = themeMode) } _state.update { it.copy(themeMode = themeMode) }
} }
} }
viewModelScope.launch {
appPreferencesStore.routeProgressNotificationEnabled.collect { enabled ->
_state.update { it.copy(routeProgressNotificationEnabled = enabled) }
}
}
viewModelScope.launch { viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads -> photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = 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 cached = syncRepository.route(routeId)
val response = cached.value val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route) photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update { _state.update {
it.copy( it.copy(
screen = DriverScreen.Detail, screen = targetScreen,
selectedRoute = response.route, 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, feedback = null,
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis, lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
@@ -283,6 +304,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
observePhotoUploads(routeId) observePhotoUploads(routeId)
observeRouteActions(routeId)
} }
fun refreshSelectedRoute() { 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() { fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } _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 val route = _state.value.selectedRoute ?: return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(error = null) } _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 -> .onFailure { throwable ->
_state.update { it.withApiError(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()) { fun uploadDispatchSheetPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val reminder = _state.value.dispatchSheetReminder ?: return val reminder = _state.value.dispatchSheetReminder ?: return
val workDate = reminder.workDate ?: 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) } _state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
} }
fun openRouteFromNotification(routeId: String?) { fun openRouteFromNotification(routeId: String?, target: String? = null) {
if (routeId.isNullOrBlank()) return if (routeId.isNullOrBlank()) return
openRoute(routeId) val targetScreen = if (target == "finish") DriverScreen.FinishRoute else DriverScreen.Detail
openRouteWithTarget(routeId, targetScreen)
} }
fun back() { fun back() {
@@ -614,12 +771,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) { when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) 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.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() 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.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone) DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
@@ -630,6 +790,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun logout() = runLoading("logout") { fun logout() = runLoading("logout") {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
routeActionsJob?.cancel()
dispatchSheetUploadsJob?.cancel() dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop() liveSyncClient.stop()
repository.logout() 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?) { private fun observeDispatchSheetUploads(workDate: String?) {
dispatchSheetUploadsJob?.cancel() dispatchSheetUploadsJob?.cancel()
if (workDate.isNullOrBlank()) { if (workDate.isNullOrBlank()) {
@@ -785,6 +955,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
override fun onCleared() { override fun onCleared() {
liveSyncClient.close() liveSyncClient.close()
photoUploadsJob?.cancel()
routeActionsJob?.cancel()
dispatchSheetUploadsJob?.cancel() dispatchSheetUploadsJob?.cancel()
super.onCleared() super.onCleared()
} }
@@ -7,6 +7,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest { class DriverUiRulesTest {
@@ -246,6 +247,44 @@ class DriverUiRulesTest {
assertEquals("Popraw", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "uploaded"), hasQueuedUpload = false)) 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 = private fun route(status: String): DriverRouteDto =
DriverRouteDto( DriverRouteDto(
id = "1", id = "1",
@@ -279,6 +318,24 @@ class DriverUiRulesTest {
serverPhotoId = serverPhotoId, 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 = private fun dispatchReminder(status: String, dueToday: Boolean = true): DispatchSheetReminderDto =
DispatchSheetReminderDto( DispatchSheetReminderDto(
enabled = dueToday, enabled = dueToday,