Compare commits

..
32 changed files with 3771 additions and 272 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 45
versionName = "1.0.43"
versionCode = 103
versionName = "1.0.50"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+7
View File
@@ -5,6 +5,8 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<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
android:allowBackup="false"
@@ -40,5 +42,10 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name=".tracking.ActiveRouteTrackingService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>
@@ -14,11 +14,13 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationRouteTarget by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
setContent {
val viewModel: DriverViewModel = viewModel()
@@ -28,6 +30,7 @@ class MainActivity : ComponentActivity() {
DriverApp(
viewModel = viewModel,
initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget,
initialLeaveRequestId = notificationLeaveRequestId,
)
}
@@ -38,11 +41,15 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent)
setIntent(intent)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
}
companion object {
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
const val EXTRA_ROUTE_TARGET = "pl.firmatpp.kierowca.EXTRA_ROUTE_TARGET"
const val EXTRA_LEAVE_REQUEST_ID = "pl.firmatpp.kierowca.EXTRA_LEAVE_REQUEST_ID"
const val ROUTE_TARGET_DETAIL = "detail"
const val ROUTE_TARGET_FINISH = "finish"
}
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
@@ -18,13 +19,23 @@ class AppPreferencesStore(
constructor(context: Context) : this(context.driverAppPreferencesDataStore)
private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue
}
}
suspend fun setRouteProgressNotificationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeProgressNotificationEnabledKey] = enabled
}
}
}
@@ -18,7 +18,9 @@ import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -26,7 +28,10 @@ import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
@@ -57,6 +62,9 @@ class DriverRepository(
suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
api.leaveCalendar(authHeader(requireToken()), from, to).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
@@ -69,6 +77,15 @@ class DriverRepository(
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
suspend fun startRoute(routeId: String, body: StartRouteBody) =
api.startRoute(authHeader(requireToken()), routeId, body)
suspend fun finishRoute(routeId: String, body: FinishRouteBody) =
api.finishRoute(authHeader(requireToken()), routeId, body)
suspend fun storeRoutePoints(routeId: String, body: RoutePointBatchBody): RoutePointsResponse =
api.storeRoutePoints(authHeader(requireToken()), routeId, body)
suspend fun updateNotificationPreferences(notifyNewRoutes: Boolean): NotificationPreferencesDto =
api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes))
@@ -95,6 +112,7 @@ class DriverRepository(
routeId: String,
uri: Uri,
source: String,
stage: String = "other",
metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
): PhotoUploadResponse {
val resolver = context.contentResolver
@@ -115,6 +133,7 @@ class DriverRepository(
clientRequestId.toPlainTextBody(),
contentSha256.toPlainTextBody(),
sourceBody,
stage.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
@@ -141,6 +160,7 @@ class DriverRepository(
upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
upload.stage.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
@@ -9,6 +9,8 @@ import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
@@ -19,7 +21,11 @@ import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.model.VerifyOtpResponse
@@ -71,6 +77,13 @@ interface MobileDriverApi {
@Header("Authorization") authorization: String,
): LeaveRequestListResponse
@GET("mobile/driver/leave-requests/calendar")
suspend fun leaveCalendar(
@Header("Authorization") authorization: String,
@Query("from") from: String,
@Query("to") to: String,
): LeaveCalendarResponse
@POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest(
@Header("Authorization") authorization: String,
@@ -102,6 +115,27 @@ interface MobileDriverApi {
@Path("routeId") routeId: String,
): CompleteRouteResponse
@POST("mobile/driver/routes/{routeId}/start")
suspend fun startRoute(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
@Body body: StartRouteBody,
): RouteActionResponse
@POST("mobile/driver/routes/{routeId}/finish")
suspend fun finishRoute(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
@Body body: FinishRouteBody,
): RouteActionResponse
@POST("mobile/driver/routes/{routeId}/points")
suspend fun storeRoutePoints(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
@Body body: RoutePointBatchBody,
): RoutePointsResponse
@GET("mobile/driver/sync-state")
suspend fun syncState(
@Header("Authorization") authorization: String,
@@ -142,6 +176,7 @@ interface MobileDriverApi {
@Part("clientRequestId") clientRequestId: RequestBody,
@Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody,
@Part("stage") stage: RequestBody?,
@Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@@ -167,6 +167,10 @@ data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(),
)
data class LeaveCalendarResponse(
val data: List<DriverLeaveCalendarEntryDto> = emptyList(),
)
data class LeaveRequestResponse(
val data: DriverLeaveRequestDto,
)
@@ -206,6 +210,22 @@ data class DriverLeaveRequestDto(
val events: List<DriverLeaveRequestEventDto> = emptyList(),
)
data class DriverLeaveCalendarEntryDto(
val id: String,
val dateFrom: String?,
val dateTo: String?,
val type: String,
val typeLabel: String? = null,
val status: String,
val driver: DriverLeaveCalendarDriverDto,
)
data class DriverLeaveCalendarDriverDto(
val id: String,
val displayName: String?,
val phoneNumber: String?,
)
data class DriverLeaveRequestEventDto(
val id: String,
val action: String,
@@ -224,6 +244,7 @@ data class RealtimeStatusBody(
data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val unloadingDate: String? = null,
val startsAt: String,
val originName: String,
val destinationName: String,
@@ -232,6 +253,10 @@ data class DriverRouteDto(
val contractCode: String?,
val relationLabel: String,
val status: String,
val loadingWeight: Double? = null,
val unloadingWeight: Double? = null,
val trackingStatus: String? = null,
val arrivedAt: String? = null,
val distanceKm: Double?,
val notes: String?,
val truck: TruckDto?,
@@ -240,6 +265,13 @@ data class DriverRouteDto(
val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null,
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(
@@ -262,6 +294,7 @@ data class RoutePhotoDto(
val clientRequestId: String? = null,
val contentSha256: String? = null,
val source: String,
val stage: String = "other",
val mimeType: String?,
val size: Long,
val url: String,
@@ -286,6 +319,55 @@ data class PhotoUploadReceiptDto(
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(
val photo: DispatchSheetPhotoDto,
val reminder: DispatchSheetReminderDto,
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.sync
import android.content.Context
import com.google.gson.Gson
import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
@@ -72,18 +73,19 @@ class DriverSyncRepository(
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
(routeVisibleDates(route) + listOfNotNull(date)).distinct().forEach { selectedDate ->
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return@forEach
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
}
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
@@ -146,6 +148,18 @@ class DriverSyncRepository(
private fun currentDateFallback(): String = java.time.LocalDate.now().toString()
private fun routeVisibleDates(route: DriverRouteDto): List<String> {
val start = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return emptyList()
val end = route.unloadingDate
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?.takeUnless { it.isBefore(start) }
?: start
return generateSequence(start) { date -> date.plusDays(1).takeIf { !it.isAfter(end) } }
.map(LocalDate::toString)
.toList()
}
companion object {
const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail"
@@ -15,16 +15,20 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
entities = [
PhotoUploadEntity::class,
DispatchSheetUploadEntity::class,
RouteActionEntity::class,
RoutePointEntity::class,
DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 3,
version = 5,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao
abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao
abstract fun routeActionDao(): RouteActionDao
abstract fun routePointDao(): RoutePointDao
abstract fun driverCacheDao(): DriverCacheDao
companion object {
@@ -105,13 +109,68 @@ abstract class DriverDatabase : RoomDatabase() {
}
}
private val migration3To4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN stage TEXT NOT NULL DEFAULT 'other'")
}
}
private val migration4To5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS route_actions (
clientActionId TEXT NOT NULL PRIMARY KEY,
routeId TEXT NOT NULL,
action TEXT NOT NULL,
weight REAL NOT NULL,
occurredAt TEXT NOT NULL,
photoClientRequestIdsJson TEXT NOT NULL,
status TEXT NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_routeId ON route_actions(routeId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_actions_status ON route_actions(status)")
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS route_points (
clientPointId TEXT NOT NULL PRIMARY KEY,
routeId TEXT NOT NULL,
driverId TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
accuracy REAL NOT NULL,
speed REAL,
bearing REAL,
timestamp INTEGER NOT NULL,
battery INTEGER,
charging INTEGER NOT NULL,
status TEXT NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_points_routeId ON route_points(routeId)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_points_status ON route_points(status)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_route_points_routeId_status ON route_points(routeId, status)")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3).build().also { instance = it }
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it }
}
}
}
@@ -16,6 +16,7 @@ data class PhotoUploadEntity(
val routeId: String,
val localPath: String,
val source: String,
val stage: String = "other",
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
@@ -28,7 +28,7 @@ class PhotoUploadOutbox(
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads()
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata): PhotoUploadEntity {
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata, stage: String = "other"): PhotoUploadEntity {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
@@ -44,6 +44,7 @@ class PhotoUploadOutbox(
routeId = routeId,
localPath = localFile.absolutePath,
source = source,
stage = stage,
takenAt = metadata.takenAt,
latitude = metadata.latitude,
longitude = metadata.longitude,
@@ -14,7 +14,10 @@ class PhotoUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).photoUploadDao()
private val database = DriverDatabase.get(appContext)
private val dao = database.photoUploadDao()
private val routeActionDao = database.routeActionDao()
private val routeActionOutbox = RouteActionOutbox(appContext)
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
@@ -62,6 +65,7 @@ class PhotoUploadWorker(
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
@@ -90,6 +94,12 @@ class PhotoUploadWorker(
}
}
private suspend fun enqueueRouteActionsWaitingForPhoto(clientRequestId: String) {
routeActionDao.waitingForPhotosActions()
.filter { routeActionReferencesPhotoClientRequest(it, clientRequestId) }
.forEach { routeActionOutbox.enqueueWorker(it.clientActionId) }
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
@@ -0,0 +1,64 @@
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 status = 'WAITING_FOR_PHOTOS'")
suspend fun waitingForPhotosActions(): List<RouteActionEntity>
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'CONFIRMED', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
ORDER BY createdAtEpochMillis DESC
""",
)
fun observeUnresolvedForRoute(routeId: String): Flow<List<RouteActionEntity>>
@Query(
"""
SELECT * FROM route_actions
WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
OR clientActionId IN (
SELECT confirmed.clientActionId
FROM route_actions AS confirmed
WHERE confirmed.status = 'CONFIRMED'
AND confirmed.routeId = route_actions.routeId
ORDER BY confirmed.createdAtEpochMillis DESC
LIMIT 2
)
ORDER BY createdAtEpochMillis DESC
""",
)
fun observeVisibleActions(): 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,69 @@
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)
fun observeVisibleActions(): Flow<List<RouteActionEntity>> =
dao.observeVisibleActions()
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
}
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,17 @@
package pl.firmatpp.kierowca.data.upload
import com.google.gson.Gson
private val routeActionPhotoDependencyGson = Gson()
fun routeActionReferencesPhotoClientRequest(action: RouteActionEntity, clientRequestId: String): Boolean {
val needle = clientRequestId.trim()
if (needle.isBlank()) return false
return runCatching {
routeActionPhotoDependencyGson
.fromJson(action.photoClientRequestIdsJson, Array<String>::class.java)
?.any { it.trim() == needle }
?: false
}.getOrDefault(false)
}
@@ -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)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
data class LeaveRequestsConfig(
val enabled: Boolean = false,
@@ -52,4 +53,41 @@ object DriverLeaveRequestUiRules {
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today)
}
fun calendarEntriesForDate(entries: List<DriverLeaveCalendarEntryDto>, date: LocalDate): List<DriverLeaveCalendarEntryDto> =
entries.filter { entry ->
val from = entry.dateFrom?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val to = entry.dateTo?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
from != null && to != null && !date.isBefore(from) && !date.isAfter(to)
}
fun calendarEntriesForRange(entries: List<DriverLeaveCalendarEntryDto>, dateFrom: String, dateTo: String): List<DriverLeaveCalendarEntryDto> {
val selectedFrom = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return emptyList()
val selectedTo = runCatching { LocalDate.parse(dateTo) }.getOrNull() ?: return emptyList()
val from = minOf(selectedFrom, selectedTo)
val to = maxOf(selectedFrom, selectedTo)
return entries.filter { entry ->
val entryFrom = entry.dateFrom?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val entryTo = entry.dateTo?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
entryFrom != null && entryTo != null && !entryTo.isBefore(from) && !entryFrom.isAfter(to)
}
}
fun phoneDialUri(phoneNumber: String?): String? {
val raw = phoneNumber?.trim().orEmpty()
if (raw.isBlank()) return null
val digits = raw.filter { it.isDigit() }
if (digits.isBlank()) return null
val normalized = when {
digits.startsWith("00") -> "+${digits.drop(2)}"
raw.startsWith("+") -> "+$digits"
digits.length == 9 -> "+48$digits"
else -> "+$digits"
}
return "tel:$normalized"
}
}
@@ -0,0 +1,78 @@
package pl.firmatpp.kierowca.ui
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
data class ProjectedDriverRoute(
val route: DriverRouteDto,
val visibleActions: List<RouteActionEntity>,
val loadingWeightPending: Boolean,
val unloadingWeightPending: Boolean,
)
internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
)
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
var projectedRoute = route
var loadingWeightPending = false
var unloadingWeightPending = false
projectable
.sortedWith(compareBy<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId })
.forEach { action ->
when (action.action) {
RouteActionType.Start -> {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
loadingWeight = action.weight,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
)
loadingWeightPending = action.status != RouteActionStatus.Confirmed
}
RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
unloadingWeight = action.weight,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
)
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
}
}
}
return ProjectedDriverRoute(
route = projectedRoute,
visibleActions = visible.sortedWith(
compareByDescending<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId },
),
loadingWeightPending = loadingWeightPending,
unloadingWeightPending = unloadingWeightPending,
)
}
fun projectDriverRoutes(routes: List<DriverRouteDto>, actions: List<RouteActionEntity>): List<DriverRouteDto> =
routes.map { route -> projectDriverRoute(route, actions).route }
fun routeActionNeedsAttention(status: String): Boolean =
status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent
internal fun DriverRouteDto.safePhotos(): List<RoutePhotoDto> = photos.orEmpty()
@@ -10,20 +10,284 @@ import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
enum class RouteFlowStepState {
Todo,
LocalComplete,
Syncing,
Confirmed,
NeedsAttention,
}
enum class RouteLifecycleAction {
Start,
Finish,
}
data class RouteFlowStepUi(
val key: String,
val title: String,
val state: RouteFlowStepState,
val stateLabel: String,
val dateLabel: String? = null,
)
data class RouteLifecycleActionUi(
val label: String,
val action: RouteLifecycleAction,
)
data class RouteSyncCalloutUi(
val text: String,
val isConflict: Boolean,
)
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
fun routeIsActiveToday(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean {
val viewedDate = runCatching { LocalDate.parse(selectedDate) }.getOrNull() ?: return false
val loadingDate = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return false
val unloadingDate = route.unloadingDate
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: loadingDate
return viewedDate == today && !today.isBefore(loadingDate) && !today.isAfter(unloadingDate)
}
fun routeUnloadingDate(route: DriverRouteDto): String? = route.unloadingDate ?: route.routeDate
fun routeScheduleDateLabel(date: String?): String =
date
?.let { runCatching { LocalDate.parse(it).format(shortDateFormatter) }.getOrNull() }
?: ""
fun routeScheduleSummary(route: DriverRouteDto): String {
val loading = routeScheduleDateLabel(route.routeDate)
val unloading = routeScheduleDateLabel(routeUnloadingDate(route))
return if (route.unloadingDate == null) {
"Załadunek: $loading · Rozładunek: ten sam dzień"
} else {
"Załadunek: $loading · Rozładunek: $unloading"
}
}
fun routeTodayInstruction(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): String? {
if (!routeIsActiveToday(route, selectedDate, today)) return null
val loadingDate = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return null
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
return when {
route.status == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
today == loadingDate -> "Dziś: załadunek."
today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
today == unloadingDate -> "Dziś: rozładunek."
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
}
}
fun canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
route.status == "W TRAKCIE"
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean {
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null
}
fun loadingPhotosSubmitBlocker(serverPhotoCount: Int, localUploadCount: Int): String? =
if (visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0) {
"Dodaj co najmniej jedno zdjęcie załadunku."
} else {
null
}
fun loadingWeightSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
return when {
trimmed.isBlank() -> "Podaj wagę."
normalizedWeight == null -> "Podaj poprawną wagę."
normalizedWeight <= 0.0 -> "Waga musi być większa od zera."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null
}
}
fun routeStageSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
return when {
trimmed.isBlank() -> "Podaj wagę."
normalizedWeight == null -> "Podaj poprawną wagę."
normalizedWeight <= 0.0 -> "Waga musi być większa od zera."
visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null
}
}
fun routeLifecyclePrimaryAction(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): RouteLifecycleActionUi? =
when {
canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Załaduj", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozładuj", RouteLifecycleAction.Finish)
else -> null
}
fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
val unresolved = actions
.filter { it.status in routeActionVisibleStatuses }
.maxByOrNull { it.createdAtEpochMillis }
?: return null
val isConflict = routeActionNeedsAttention(unresolved.status)
return RouteSyncCalloutUi(
text = if (isConflict) {
"Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane."
} else {
when (unresolved.status) {
RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu."
RouteActionStatus.Pending,
RouteActionStatus.Syncing -> "Trwa wysyłanie zmiany statusu kursu."
RouteActionStatus.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie."
else -> "Zmiana zapisana w telefonie. Aplikacja wyśle ją automatycznie."
}
},
isConflict = isConflict,
)
}
fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): List<RouteFlowStepUi> {
val startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
val loadingPhotosState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete
else -> RouteFlowStepState.Todo
}
val loadingWeightState = startOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
else -> RouteFlowStepState.Todo
}
val transitState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
finishOverride == RouteFlowStepState.NeedsAttention -> RouteFlowStepState.LocalComplete
finishOverride == RouteFlowStepState.Syncing -> RouteFlowStepState.LocalComplete
route.status == "W TRAKCIE" -> RouteFlowStepState.LocalComplete
else -> RouteFlowStepState.Todo
}
val unloadingState = finishOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.unloadingWeight != null -> RouteFlowStepState.LocalComplete
else -> RouteFlowStepState.Todo
}
return listOf(
RouteFlowStepUi("loading_photos", "Zdjęcia załadunku", loadingPhotosState, routeFlowStateLabel(loadingPhotosState), routeScheduleDateLabel(route.routeDate)),
RouteFlowStepUi("loading_weight", "Waga załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState), routeScheduleDateLabel(route.routeDate)),
RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState), routeScheduleDateLabel(routeUnloadingDate(route))),
)
}
fun routePhotosForStage(photos: List<RoutePhotoDto>?, stage: String): List<RoutePhotoDto> {
val normalized = normalizedRoutePhotoStage(stage)
return photos.orEmpty().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
private fun routeActionStepState(actions: List<RouteActionEntity>, actionType: String): RouteFlowStepState? {
val action = actions
.filter { it.action == actionType && it.status in routeActionVisibleStatuses }
.maxByOrNull { it.createdAtEpochMillis }
?: return null
return when (action.status) {
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent -> RouteFlowStepState.NeedsAttention
else -> RouteFlowStepState.Syncing
}
}
private fun routeFlowStateLabel(state: RouteFlowStepState): String =
when (state) {
RouteFlowStepState.Todo -> "do zrobienia"
RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie"
RouteFlowStepState.Syncing -> "synchronizacja"
RouteFlowStepState.Confirmed -> "potwierdzone"
RouteFlowStepState.NeedsAttention -> "wymaga uwagi"
}
fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
@@ -65,6 +329,12 @@ fun shouldShowPhotoPreviewLoadingState(hasPhoto: Boolean, isLoading: Boolean, is
fun shouldShowPhotoPreviewErrorState(hasPhoto: Boolean, isError: Boolean): Boolean =
hasPhoto && isError
fun shouldShowPhotoTileLoadingState(hasPhoto: Boolean, isLoading: Boolean, isError: Boolean): Boolean =
hasPhoto && isLoading && !isError
fun shouldShowPhotoTileErrorState(hasPhoto: Boolean, isError: Boolean): Boolean =
hasPhoto && isError
fun authBrandBannerHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 118
@@ -132,6 +402,7 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
clientRequestId = upload.clientRequestId,
contentSha256 = upload.contentSha256,
source = upload.source,
stage = normalizedRoutePhotoStage(upload.stage),
mimeType = upload.mimeType,
size = upload.size,
url = File(upload.localPath).toURI().toString(),
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.Instant
import pl.firmatpp.kierowca.data.AppPreferencesStore
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind
@@ -20,6 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -27,13 +29,16 @@ import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionOutbox
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker
import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest }
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, StartRoute, LoadingWeight, FinishRoute, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, LeaveCalendar, AddLeaveRequest }
data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing,
@@ -52,9 +57,13 @@ data class DriverUiState(
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
val notifyNewRoutes: Boolean = false,
val routeProgressNotificationEnabled: Boolean = true,
val notificationPermissionDenied: Boolean = false,
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "",
val routeActions: List<RouteActionEntity> = emptyList(),
val visibleRouteActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
@@ -66,6 +75,11 @@ data class DriverUiState(
val leaveRequestDateTo: String = LocalDate.now().toString(),
val leaveRequestType: String = "URLOP",
val leaveRequestNote: String = "",
val leaveCalendarEntries: List<DriverLeaveCalendarEntryDto> = emptyList(),
val leaveCalendarLoading: Boolean = false,
val leaveCalendarLoadedUntil: String? = null,
val leaveCalendarHasSelection: Boolean = false,
val leaveCalendarSelectionAnchor: String? = null,
val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false,
val imageAuthHeader: String? = null,
@@ -76,7 +90,20 @@ data class DriverUiState(
val themeMode: AppThemeMode = AppThemeMode.Default,
val feedback: String? = null,
val error: String? = null,
)
) {
private val projectionActions: List<RouteActionEntity>
get() = (visibleRouteActions + routeActions).distinctBy { it.clientActionId }
val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions)
.sortedBy { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 }
val selectedRouteProjection: ProjectedDriverRoute?
get() = selectedRoute?.let { projectDriverRoute(it, projectionActions) }
val displaySelectedRoute: DriverRouteDto?
get() = selectedRouteProjection?.route
}
private fun DriverUiState.withApiError(throwable: Throwable): DriverUiState {
val apiError = ApiErrorMapper.map(throwable)
@@ -108,6 +135,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application)
private val routeActionOutbox = RouteActionOutbox(application)
private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
@@ -117,6 +145,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } },
)
private var photoUploadsJob: Job? = null
private var routeActionsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state
@@ -127,11 +156,21 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(themeMode = themeMode) }
}
}
viewModelScope.launch {
appPreferencesStore.routeProgressNotificationEnabled.collect { enabled ->
_state.update { it.copy(routeProgressNotificationEnabled = enabled) }
}
}
viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) }
}
}
viewModelScope.launch {
routeActionOutbox.observeVisibleActions().collect { actions ->
_state.update { it.copy(visibleRouteActions = actions) }
}
}
viewModelScope.launch {
networkMonitor.isOnline.collect { online ->
liveSyncClient.onNetworkAvailable(online)
@@ -268,14 +307,21 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun openRoute(routeId: String) = runLoading("open_route", mapOf("route_id" to routeId)) {
fun openRoute(routeId: String) = openRouteWithTarget(routeId, DriverScreen.Detail)
private fun openRouteWithTarget(routeId: String, targetScreen: DriverScreen) = runLoading("open_route", mapOf("route_id" to routeId)) {
val cached = syncRepository.route(routeId)
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
screen = DriverScreen.Detail,
screen = targetScreen,
selectedRoute = response.route,
routeStageWeightText = when (targetScreen) {
DriverScreen.LoadingWeight -> response.route.loadingWeight?.toString().orEmpty()
DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty()
else -> it.routeStageWeightText
},
feedback = null,
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
@@ -283,6 +329,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
observePhotoUploads(routeId)
observeRouteActions(routeId)
}
fun refreshSelectedRoute() {
@@ -355,6 +402,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun setRouteProgressNotificationEnabled(enabled: Boolean) {
_state.update { it.copy(routeProgressNotificationEnabled = enabled) }
viewModelScope.launch {
runCatching { appPreferencesStore.setRouteProgressNotificationEnabled(enabled) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
@@ -400,15 +455,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val today = LocalDate.now().toString()
_state.update {
it.copy(
screen = DriverScreen.AddLeaveRequest,
screen = DriverScreen.LeaveCalendar,
leaveRequestDateFrom = today,
leaveRequestDateTo = today,
leaveRequestType = config?.types?.firstOrNull() ?: "URLOP",
leaveRequestNote = "",
leaveCalendarEntries = emptyList(),
leaveCalendarLoadedUntil = null,
leaveCalendarHasSelection = false,
leaveCalendarSelectionAnchor = null,
error = null,
feedback = null,
)
}
loadMoreLeaveCalendarMonths(reset = true)
}
fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) {
@@ -424,6 +484,99 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun selectLeaveCalendarDate(date: String) {
val selected = runCatching { LocalDate.parse(date) }.getOrNull() ?: return
if (selected.isBefore(LocalDate.now())) return
_state.update {
val anchor = it.leaveCalendarSelectionAnchor
if (!it.leaveCalendarHasSelection || anchor == null) {
it.copy(
leaveRequestDateFrom = date,
leaveRequestDateTo = date,
leaveCalendarHasSelection = true,
leaveCalendarSelectionAnchor = date,
error = null,
)
} else {
val start = LocalDate.parse(anchor)
val from = minOf(start, selected).toString()
val to = maxOf(start, selected).toString()
it.copy(
leaveRequestDateFrom = from,
leaveRequestDateTo = to,
leaveCalendarHasSelection = true,
leaveCalendarSelectionAnchor = null,
error = null,
)
}
}
}
fun continueAddLeaveRequestFromCalendar() {
if (!_state.value.leaveCalendarHasSelection) {
_state.update { it.copy(error = "Wybierz termin urlopu w kalendarzu.") }
return
}
_state.update { it.copy(screen = DriverScreen.AddLeaveRequest, error = null, feedback = null) }
}
fun reopenLeaveCalendarFromDraft() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
_state.update {
it.copy(
screen = DriverScreen.LeaveCalendar,
error = null,
feedback = null,
)
}
if (_state.value.leaveCalendarLoadedUntil == null) {
loadMoreLeaveCalendarMonths(reset = true)
}
}
fun loadMoreLeaveCalendarMonths(reset: Boolean = false) {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig) || snapshot.leaveCalendarLoading) return
val from = if (reset || snapshot.leaveCalendarLoadedUntil == null) {
LocalDate.now()
} else {
runCatching { LocalDate.parse(snapshot.leaveCalendarLoadedUntil).plusDays(1) }.getOrDefault(LocalDate.now())
}
val to = from.plusMonths(6).minusDays(1)
viewModelScope.launch {
_state.update {
it.copy(
leaveCalendarLoading = true,
error = null,
leaveCalendarEntries = if (reset) emptyList() else it.leaveCalendarEntries,
leaveCalendarLoadedUntil = if (reset) null else it.leaveCalendarLoadedUntil,
)
}
runCatching { repository.leaveCalendar(from.toString(), to.toString()) }
.onSuccess { entries ->
_state.update {
val merged = (it.leaveCalendarEntries + entries)
.distinctBy { entry -> entry.id }
.sortedWith(compareBy<DriverLeaveCalendarEntryDto> { entry -> entry.dateFrom.orEmpty() }.thenBy { entry -> entry.id })
it.copy(
leaveCalendarEntries = merged,
leaveCalendarLoadedUntil = to.toString(),
leaveCalendarLoading = false,
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) }
}
}
}
fun submitLeaveRequest() {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
@@ -481,17 +634,205 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val route = _state.value.selectedRoute ?: return
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other") {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (!routeIsActiveToday(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") }
return
}
viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) }
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
}
}
fun openStartRoute() {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return
}
_state.update {
it.copy(
screen = DriverScreen.StartRoute,
routeStageWeightText = "",
feedback = null,
error = null,
)
}
}
fun openLoadingWeight() {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
val stagePhotos = routePhotosForStage(route.photos, "loading")
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" }
val blocker = loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
if (blocker != null) {
_state.update { it.copy(error = blocker) }
return
}
_state.update {
it.copy(
screen = DriverScreen.LoadingWeight,
routeStageWeightText = route.loadingWeight?.toString().orEmpty(),
feedback = null,
error = null,
)
}
}
fun openFinishRoute() {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (!canFinishRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Rozładunek jest dostępny w dniu zaplanowanego rozładunku.") }
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 canSubmitStage = if (stage == "loading") {
canStartRouteFromDriverApp(route, snapshot.selectedDate)
} else {
canFinishRouteFromDriverApp(route, snapshot.selectedDate)
}
if (!canSubmitStage) {
_state.update {
it.copy(error = if (stage == "loading") {
"Załadunek nie jest dostępny poza zaplanowanym zakresem kursu."
} else {
"Rozładunek jest dostępny w dniu zaplanowanego rozładunku."
})
}
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 }
val submitBlocker = if (stage == "loading") {
loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
} else {
routeStageSubmitBlocker(
weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
}
if (weight == null || submitBlocker != null) {
_state.update { it.copy(error = submitBlocker ?: "Podaj poprawną wagę.") }
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",
photos = route.safePhotos(),
)
}
"unloading" -> {
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds)
route.copy(
status = "ZAKOŃCZONA",
unloadingWeight = weight,
trackingStatus = "finished",
completedAt = Instant.now().toString(),
photos = route.safePhotos(),
)
}
else -> route
}
syncRepository.cacheConfirmedRoute(updatedRoute, snapshot.selectedDate)
_state.update {
it.copy(
screen = DriverScreen.Detail,
selectedRoute = updatedRoute,
routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item },
routeStageWeightText = "",
feedback = if (stage == "loading") {
"Kurs rozpoczęty lokalnie. Synchronizacja pójdzie w tle."
} else {
"Kurs zakończony lokalnie. Synchronizacja pójdzie w tle."
},
error = null,
)
}
if (stage == "loading" && driver != null) {
ActiveRouteTrackingService.start(
context = getApplication(),
route = updatedRoute,
driver = driver,
progressEnabled = snapshot.routeProgressNotificationEnabled,
)
}
if (stage == "unloading") {
ActiveRouteTrackingService.stop(getApplication())
}
}.onFailure { throwable ->
reportHandledException("submit_route_stage", throwable, mapOf("route_id" to route.id, "stage" to stage))
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
}
}
fun uploadDispatchSheetPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val reminder = _state.value.dispatchSheetReminder ?: return
val workDate = reminder.workDate ?: return
@@ -570,8 +911,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return
if (_state.value.completingRoute || !_state.value.isOnline) return
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (snapshot.completingRoute || !snapshot.isOnline) return
if (!canCompleteRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Zakończenie kursu jest dostępne w dniu zaplanowanego rozładunku.") }
return
}
viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
@@ -604,9 +950,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
}
fun openRouteFromNotification(routeId: String?) {
fun openRouteFromNotification(routeId: String?, target: String? = null) {
if (routeId.isNullOrBlank()) return
openRoute(routeId)
val targetScreen = if (target == "finish") DriverScreen.FinishRoute else DriverScreen.Detail
openRouteWithTarget(routeId, targetScreen)
}
fun back() {
@@ -614,12 +961,17 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null)
DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> {
photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
routeActionsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, routeActions = emptyList(), photoUploads = emptyList(), feedback = null)
}
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
@@ -630,6 +982,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun logout() = runLoading("logout") {
photoUploadsJob?.cancel()
routeActionsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop()
repository.logout()
@@ -664,6 +1017,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
private fun observeRouteActions(routeId: String) {
routeActionsJob?.cancel()
routeActionsJob = viewModelScope.launch {
routeActionOutbox.observeUnresolvedForRoute(routeId).collect { actions ->
_state.update { it.copy(routeActions = actions) }
}
}
}
private fun observeDispatchSheetUploads(workDate: String?) {
dispatchSheetUploadsJob?.cancel()
if (workDate.isNullOrBlank()) {
@@ -785,6 +1147,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
override fun onCleared() {
liveSyncClient.close()
photoUploadsJob?.cancel()
routeActionsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
super.onCleared()
}
@@ -0,0 +1,31 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RouteActionPhotoDependenciesTest {
@Test
fun detectsRouteActionWaitingForConfirmedPhotoClientRequest() {
val action = action("""["photo-a","photo-b"]""")
assertTrue(routeActionReferencesPhotoClientRequest(action, "photo-b"))
assertFalse(routeActionReferencesPhotoClientRequest(action, "photo-c"))
}
@Test
fun malformedPhotoRequestJsonDoesNotMatch() {
assertFalse(routeActionReferencesPhotoClientRequest(action("not-json"), "photo-a"))
}
private fun action(photoClientRequestIdsJson: String): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-1",
routeId = "route-1",
action = RouteActionType.Start,
weight = 12.5,
occurredAt = "2026-06-30T07:40:00Z",
photoClientRequestIdsJson = photoClientRequestIdsJson,
status = RouteActionStatus.WaitingForPhotos,
)
}
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
class DriverLeaveRequestUiRulesTest {
@Test
@@ -48,4 +50,59 @@ class DriverLeaveRequestUiRulesTest {
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow))
}
@Test
fun findsCalendarEntriesThatOverlapSelectedDate() {
val entries = listOf(
calendarEntry(id = "week", dateFrom = "2026-08-03", dateTo = "2026-08-09"),
calendarEntry(id = "single-day", dateFrom = "2026-08-05", dateTo = "2026-08-05"),
calendarEntry(id = "outside", dateFrom = "2026-08-10", dateTo = "2026-08-12"),
)
val matching = DriverLeaveRequestUiRules.calendarEntriesForDate(entries, LocalDate.parse("2026-08-05"))
assertEquals(listOf("week", "single-day"), matching.map { it.id })
}
@Test
fun findsCalendarEntriesThatOverlapSelectedRange() {
val entries = listOf(
calendarEntry(id = "starts-before", dateFrom = "2026-08-01", dateTo = "2026-08-04"),
calendarEntry(id = "inside", dateFrom = "2026-08-06", dateTo = "2026-08-07"),
calendarEntry(id = "ends-after", dateFrom = "2026-08-09", dateTo = "2026-08-12"),
calendarEntry(id = "before", dateFrom = "2026-07-20", dateTo = "2026-07-22"),
calendarEntry(id = "after", dateFrom = "2026-08-13", dateTo = "2026-08-14"),
)
val matching = DriverLeaveRequestUiRules.calendarEntriesForRange(
entries = entries,
dateFrom = "2026-08-03",
dateTo = "2026-08-10",
)
assertEquals(listOf("starts-before", "inside", "ends-after"), matching.map { it.id })
}
@Test
fun normalizesPhoneNumberIntoDialerUri() {
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("+48 600 700 800"))
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("0048 600 700 800"))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(null))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(" "))
}
private fun calendarEntry(id: String, dateFrom: String, dateTo: String): DriverLeaveCalendarEntryDto =
DriverLeaveCalendarEntryDto(
id = id,
dateFrom = dateFrom,
dateTo = dateTo,
type = "URLOP",
typeLabel = "Urlop",
status = "approved",
driver = DriverLeaveCalendarDriverDto(
id = "driver-$id",
displayName = "Kierowca $id",
phoneNumber = "+48 600 700 800",
),
)
}
@@ -0,0 +1,194 @@
package pl.firmatpp.kierowca.ui
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverRouteProjectionTest {
@Test
fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)),
)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertEquals("active", projection.route.trackingStatus)
assertTrue(projection.loadingWeightPending)
assertFalse(projection.unloadingWeightPending)
assertEquals(RouteLifecycleAction.Finish, routeLifecyclePrimaryAction(projection.route, "2026-06-30", today)?.action)
}
@Test
fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() {
val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.Syncing, weight = 11.8, occurredAt = "2026-06-30T09:10:00Z")),
)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("finished", projection.route.trackingStatus)
assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt)
assertTrue(projection.unloadingWeightPending)
assertNull(routeLifecyclePrimaryAction(projection.route, "2026-06-30", today))
}
@Test
fun startAndFinishAreAppliedChronologicallySoFinishWins() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(
action(RouteActionType.Finish, RouteActionStatus.Pending, weight = 11.8, createdAt = 2000),
action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5, createdAt = 1000),
),
)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("finished", projection.route.trackingStatus)
}
@Test
fun confirmedFinishStillProjectsPlannedRouteAsFinishedWithoutSyncCallout() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(
action(RouteActionType.Finish, RouteActionStatus.Confirmed, weight = 11.8, createdAt = 2000),
),
)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("finished", projection.route.trackingStatus)
assertFalse(projection.unloadingWeightPending)
assertEquals(emptyList<RouteActionEntity>(), projection.visibleActions)
}
@Test
fun routeProjectionNormalizesNullPhotosFromCachedJsonBeforeCopy() {
val route = Gson().fromJson(
"""
{
"id": "1",
"startsAt": "",
"originName": "Baza",
"destinationName": "Instalacja",
"contractorName": "TPP",
"contractName": "Kontrakt",
"contractCode": "TPP-1",
"relationLabel": "Baza -> Instalacja",
"status": "ZAPLANOWANA",
"distanceKm": 1.0,
"notes": null,
"truck": null,
"photos": null
}
""".trimIndent(),
DriverRouteDto::class.java,
)
val projection = projectDriverRoute(
route = route,
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Confirmed, weight = 12.5)),
)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(emptyList<RoutePhotoDto>(), projection.route.photos)
}
@Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)),
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(projection.visibleActions)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
}
@Test
fun failedPermanentIsVisibleAndNeedsAttention() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(action(RouteActionType.Start, RouteActionStatus.FailedPermanent, weight = 12.5)),
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
}
@Test
fun noLocalActionsLeavesRouteUnchanged() {
val route = route(status = "ZAPLANOWANA")
val projection = projectDriverRoute(route, emptyList())
assertEquals(route, projection.route)
assertFalse(projection.loadingWeightPending)
assertFalse(projection.unloadingWeightPending)
assertEquals(emptyList<RouteActionEntity>(), projection.visibleActions)
}
private fun route(
status: String,
loadingWeight: Double? = null,
unloadingWeight: Double? = null,
): DriverRouteDto =
DriverRouteDto(
id = "1",
routeDate = "2026-06-30",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
loadingWeight = loadingWeight,
unloadingWeight = unloadingWeight,
distanceKm = 1.0,
notes = null,
truck = null,
)
private fun action(
type: String,
status: String,
weight: Double,
occurredAt: String = "2026-06-30T07:40:00Z",
createdAt: Long = 1000,
): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-$type-$status-$createdAt",
routeId = "1",
action = type,
weight = weight,
occurredAt = occurredAt,
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
)
private companion object {
val today = java.time.LocalDate.parse("2026-06-30")
}
}
@@ -7,7 +7,11 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30")
@@ -35,6 +39,32 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test
fun multiDayRouteCanLoadTodayButCanUnloadOnlyOnItsUnloadingDate() {
val route = route(
status = "ZAPLANOWANA",
routeDate = "2026-06-29",
unloadingDate = "2026-06-30",
)
assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today))
assertEquals("Załaduj", routeLifecyclePrimaryAction(route, "2026-06-30", today)?.label)
assertEquals("Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj.", routeTodayInstruction(route, "2026-06-30", today))
val activeRoute = route.copy(status = "W TRAKCIE")
assertTrue(canFinishRouteFromDriverApp(activeRoute, "2026-06-30", today))
assertEquals("Rozładuj", routeLifecyclePrimaryAction(activeRoute, "2026-06-30", today)?.label)
assertFalse(canFinishRouteFromDriverApp(activeRoute, "2026-06-29", LocalDate.parse("2026-06-29")))
}
@Test
fun routeScheduleShowsBothDispatcherDates() {
val route = route(routeDate = "2026-06-30", unloadingDate = "2026-07-02")
assertEquals("Załadunek: 30.06 · Rozładunek: 02.07", routeScheduleSummary(route))
assertEquals("Załadunek: 30.06 · Rozładunek: ten sam dzień", routeScheduleSummary(route.copy(unloadingDate = null)))
}
@Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
@@ -73,6 +103,18 @@ class DriverUiRulesTest {
assertFalse(shouldShowPhotoPreviewErrorState(hasPhoto = false, isError = true))
}
@Test
fun keepsPhotoTileFeedbackVisibleUntilThumbnailLoadsOrFails() {
assertTrue(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = true, isError = false))
assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = false, isError = false))
assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = true, isLoading = true, isError = true))
assertFalse(shouldShowPhotoTileLoadingState(hasPhoto = false, isLoading = true, isError = false))
assertTrue(shouldShowPhotoTileErrorState(hasPhoto = true, isError = true))
assertFalse(shouldShowPhotoTileErrorState(hasPhoto = true, isError = false))
assertFalse(shouldShowPhotoTileErrorState(hasPhoto = false, isError = true))
}
@Test
fun usesShorterChromeOnCompactScreens() {
assertEquals(118, authBrandBannerHeightDp(600))
@@ -234,9 +276,167 @@ class DriverUiRulesTest {
assertEquals("Popraw", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "uploaded"), hasQueuedUpload = false))
}
private fun route(status: String): DriverRouteDto =
@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))
}
@Test
fun plannedRouteShowsLoadingAsNextStepAndStartAction() {
val route = route(status = "ZAPLANOWANA")
val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(listOf("Zdjęcia załadunku", "Waga załadunku", "W trasie", "Rozładunek"), steps.map { it.title })
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Załaduj", action?.label)
assertEquals(RouteLifecycleAction.Start, action?.action)
}
@Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.LocalComplete, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun activeRouteShowsTransitStateAndFinishAction() {
val route = route(status = "W TRAKCIE", loadingWeight = 12.5)
val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action)
}
@Test
fun finishedRouteShowsCompletedStagesAndNoPrimaryAction() {
val route = route(status = "ZAKOŃCZONA", loadingWeight = 12.5, unloadingWeight = 11.8)
val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.Confirmed, steps[2].state)
assertEquals(RouteFlowStepState.Confirmed, steps[3].state)
assertEquals(null, action)
}
@Test
fun pendingLocalRouteActionShowsSyncingCallout() {
val actions = listOf(action(RouteActionType.Start, RouteActionStatus.WaitingForPhotos))
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(actions)
assertEquals(RouteFlowStepState.Syncing, steps[1].state)
assertEquals("Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu.", callout?.text)
assertFalse(callout?.isConflict ?: true)
}
@Test
fun failedConflictRouteActionRequiresAttention() {
val actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, "Kurs został przepisany."))
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(actions)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
}
@Test
fun routeStageSubmitBlockerExplainsMissingRequirements() {
assertEquals("Podaj wagę.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Dodaj co najmniej jedno zdjęcie etapu.", routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0))
assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
@Test
fun loadingPhotosStepRequiresAtLeastOneLoadingPhotoBeforeWeightStep() {
assertEquals(
"Dodaj co najmniej jedno zdjęcie załadunku.",
loadingPhotosSubmitBlocker(serverPhotoCount = 0, localUploadCount = 0),
)
assertEquals(null, loadingPhotosSubmitBlocker(serverPhotoCount = 1, localUploadCount = 0))
assertEquals(null, loadingPhotosSubmitBlocker(serverPhotoCount = 0, localUploadCount = 1))
}
@Test
fun loadingWeightStepRequiresPositiveWeightAndPreviousLoadingPhoto() {
assertEquals("Podaj wagę.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj poprawną wagę.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals(
"Dodaj co najmniej jedno zdjęcie załadunku.",
loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0),
)
assertEquals(null, loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
private fun route(
status: String = "ZAPLANOWANA",
loadingWeight: Double? = null,
unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(),
routeDate: String? = "2026-06-30",
unloadingDate: String? = null,
): DriverRouteDto =
DriverRouteDto(
id = "1",
routeDate = routeDate,
unloadingDate = unloadingDate,
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
@@ -245,9 +445,24 @@ class DriverUiRulesTest {
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
loadingWeight = loadingWeight,
unloadingWeight = unloadingWeight,
distanceKm = 1.0,
notes = null,
truck = null,
photos = photos,
)
private fun action(type: String, status: String, lastError: String? = null): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-$type-$status",
routeId = "1",
action = type,
weight = 12.5,
occurredAt = "2026-06-30T07:40:00Z",
photoClientRequestIdsJson = "[]",
status = status,
lastError = lastError,
)
private fun upload(status: String, serverPhotoId: String? = null): PhotoUploadEntity =
@@ -267,6 +482,24 @@ class DriverUiRulesTest {
serverPhotoId = serverPhotoId,
)
private fun photo(id: String, stage: String?): RoutePhotoDto =
RoutePhotoDto(
id = id,
routeId = "10",
clientRequestId = null,
contentSha256 = null,
source = "camera",
stage = stage ?: "other",
mimeType = "image/jpeg",
size = 1200,
url = "https://example.test/$id.jpg",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
private fun dispatchReminder(status: String, dueToday: Boolean = true): DispatchSheetReminderDto =
DispatchSheetReminderDto(
enabled = dueToday,
@@ -4,6 +4,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
class DriverUiStateTest {
@@ -51,6 +55,34 @@ class DriverUiStateTest {
assertEquals(1, refreshed.leaveRequests.size)
}
@Test
fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() {
val route = route(status = "ZAPLANOWANA")
val state = DriverUiState(
routes = listOf(route),
selectedRoute = route,
visibleRouteActions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)),
)
assertEquals("W TRAKCIE", state.displayRoutes.single().status)
assertEquals(12.5, state.displayRoutes.single().loadingWeight)
assertEquals("W TRAKCIE", state.displaySelectedRoute?.status)
assertEquals(12.5, state.displaySelectedRoute?.loadingWeight)
}
@Test
fun displayRoutesMovesCompletedRoutesBelowActiveDispatcherOrder() {
val state = DriverUiState(
routes = listOf(
route(id = "completed", status = "ZAKOŃCZONA"),
route(id = "planned", status = "ZAPLANOWANA"),
route(id = "active", status = "W TRAKCIE"),
),
)
assertEquals(listOf("planned", "active", "completed"), state.displayRoutes.map { it.id })
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
@@ -59,4 +91,32 @@ class DriverUiStateTest {
type = "URLOP",
status = status,
)
private fun route(id: String = "1", status: String): DriverRouteDto =
DriverRouteDto(
id = id,
routeDate = "2026-06-30",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
distanceKm = 1.0,
notes = null,
truck = null,
)
private fun action(type: String, status: String, weight: Double): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-$type-$status",
routeId = "1",
action = type,
weight = weight,
occurredAt = "2026-06-30T07:40:00Z",
photoClientRequestIdsJson = "[]",
status = status,
)
}