Compare commits

...
30 Commits
Author SHA1 Message Date
admin 37407e2737 Fix offline route lifecycle projection 2026-07-07 22:29:35 +02:00
admin 13159c576e Implement driver route lifecycle flow 2026-07-07 12:29:12 +02:00
admin 1834763f19 Add route detail photo tile loaders 2026-07-05 23:29:49 +02:00
admin 62c0469669 Add photo preview loading state 2026-07-05 23:14:07 +02:00
admin 3d6e1624e9 Fix leave request detail refresh navigation 2026-07-05 11:39:18 +02:00
admin b1a34105f1 Harden driver app realtime release 2026-07-05 03:15:54 +02:00
admin 72db88b25c Add Firebase Crashlytics diagnostics 2026-07-05 00:06:47 +02:00
admin 97f0eb56d5 Polish driver leave request status labels 2026-07-04 23:41:59 +02:00
admin c6d6392656 Refine driver leave request mobile flow 2026-07-04 23:26:26 +02:00
admin 958fb6eaa6 Bump Android driver app to 1.0.38 2026-07-04 23:18:42 +02:00
admin 2c806ef14e Add driver leave request screens 2026-07-04 23:11:08 +02:00
admin 0bbdbdb4f1 chore: bump Android app to 1.0.37 2026-07-04 12:06:46 +02:00
admin fe2e2e589a feat: add dispatch sheet reminder to driver app 2026-07-04 12:03:34 +02:00
admin e0115e85fc Pin live update toast over routes 2026-07-02 16:29:49 +02:00
admin 0e15d5f9a4 Show live day updates and route date chip 2026-07-02 16:24:06 +02:00
admin 5555676395 Shorten driver Reverb heartbeat 2026-07-02 16:08:24 +02:00
admin ca512f5993 Load Reverb config from mobile bootstrap 2026-07-02 15:33:42 +02:00
admin 64d6f5a41d Add new route push notifications 2026-07-02 15:25:54 +02:00
admin a3d39764e2 Report driver Reverb connection status 2026-07-02 14:58:12 +02:00
admin 705b8904a4 Reconcile confirmed photo uploads with server state 2026-07-02 14:42:52 +02:00
admin 541dda292f Add photo upload failure details 2026-07-01 22:47:13 +02:00
admin fa98451533 Add photo upload retry controls 2026-07-01 22:39:47 +02:00
admin ee9d8cdbaf Update driver app photo handling 2026-07-01 19:26:28 +02:00
admin 76326bbc27 Fix launcher icon clipping for 1.0.22 2026-07-01 14:49:11 +02:00
admin ed02da031b Refine launcher icon for 1.0.21 2026-07-01 14:44:06 +02:00
admin a4e24da717 Fix launcher icon safe area for 1.0.20 2026-07-01 14:20:54 +02:00
admin ab84205bec Update Android app icon for 1.0.18 2026-07-01 12:46:38 +02:00
admin 9ba1b1fa13 Dodaj live sync i kolejkę zdjęć 2026-07-01 10:59:21 +02:00
admin 06557e77b8 Dodaj usuwanie świeżo zapisanych zdjęć 2026-07-01 02:43:52 +02:00
admin a08fe31d3d Popraw obsługę galerii zdjęć kierowcy 2026-07-01 02:37:43 +02:00
106 changed files with 8682 additions and 541 deletions
+4
View File
@@ -1,5 +1,9 @@
.gradle/
build/
local.properties
app/google-services.json
app/google-services.json:Zone.Identifier
*/build/
*.iml
keystore.properties
*.jks
+51 -2
View File
@@ -1,3 +1,6 @@
import java.io.File
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,19 +8,58 @@ plugins {
alias(libs.plugins.ksp)
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use(::load)
}
}
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
fun configValue(name: String, default: String): String =
providers.gradleProperty(name)
.orElse(providers.environmentVariable(name))
.orElse(default)
.get()
defaultConfig {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 15
versionName = "1.0.14"
versionCode = 47
versionName = "1.0.44"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
buildConfigField("String", "REVERB_APP_KEY", "\"${configValue("REVERB_APP_KEY", "")}\"")
buildConfigField("String", "REVERB_WS_BASE_URL", "\"${configValue("REVERB_WS_BASE_URL", "wss://api-intranet.firmatpp.pl/app/")}\"")
}
signingConfigs {
create("release") {
fun requiredSigningProperty(name: String): String =
keystoreProperties.getProperty(name)
?: throw GradleException("Missing '$name' in keystore.properties")
val configuredStoreFile = requiredSigningProperty("storeFile")
storeFile = if (File(configuredStoreFile).isAbsolute) {
file(configuredStoreFile)
} else {
rootProject.file(configuredStoreFile)
}
storePassword = requiredSigningProperty("storePassword")
keyAlias = requiredSigningProperty("keyAlias")
keyPassword = requiredSigningProperty("keyPassword")
}
}
buildTypes {
@@ -25,7 +67,11 @@ android {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -77,6 +123,9 @@ dependencies {
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.coroutines.android)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.play.services.auth)
+17
View File
@@ -4,11 +4,15 @@
<uses-permission android:name="android.permission.CAMERA" />
<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"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".DriverApplication"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.TppKierowca">
@@ -30,5 +34,18 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".sync.DriverFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name=".tracking.ActiveRouteTrackingService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>
@@ -0,0 +1,12 @@
package pl.firmatpp.kierowca
import android.app.Application
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverApplication : Application() {
override fun onCreate() {
super.onCreate()
AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started")
}
}
@@ -3,19 +3,53 @@ package pl.firmatpp.kierowca
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel
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 {
TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel()
DriverApp(viewModel = viewModel)
val viewModel: DriverViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle()
TppKierowcaTheme(themeMode = state.themeMode) {
DriverApp(
viewModel = viewModel,
initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget,
initialLeaveRequestId = notificationLeaveRequestId,
)
}
}
}
override fun onNewIntent(intent: android.content.Intent) {
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"
}
}
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import retrofit2.HttpException
enum class ApiErrorKind {
@@ -20,11 +21,17 @@ data class ApiError(
val retryable: Boolean,
val code: String? = null,
val statusCode: Int? = null,
val responseBody: String? = null,
)
object ApiErrorMapper {
fun map(throwable: Throwable): ApiError =
when (throwable) {
is UnknownHostException -> ApiError(
kind = ApiErrorKind.Network,
message = "Brak internetu lub połączenia z serwerem. Sprawdź zasięg i spróbuj ponownie.",
retryable = true,
)
is IOException -> ApiError(
kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.",
@@ -38,6 +45,9 @@ object ApiErrorMapper {
)
}
fun shouldReportNonFatal(throwable: Throwable): Boolean =
map(throwable).kind != ApiErrorKind.Network
fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
@@ -48,10 +58,17 @@ object ApiErrorMapper {
code = code,
message = message,
retryable = retryable,
responseBody = body,
)
}
fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError {
fun mapProblem(
statusCode: Int,
code: String?,
message: String?,
retryable: Boolean?,
responseBody: String? = null,
): ApiError {
val kind = when (statusCode) {
401 -> ApiErrorKind.Auth
403 -> ApiErrorKind.Forbidden
@@ -80,6 +97,7 @@ object ApiErrorMapper {
retryable = resolvedRetryable,
code = code,
statusCode = statusCode,
responseBody = responseBody,
)
}
@@ -0,0 +1,41 @@
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
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
private val Context.driverAppPreferencesDataStore by preferencesDataStore(name = "driver_app_preferences")
class AppPreferencesStore(
private val dataStore: DataStore<Preferences>,
) {
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
}
}
}
@@ -12,12 +12,28 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.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.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
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
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverRepository(
@@ -42,10 +58,57 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data
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))
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
api.syncState(authHeader(requireToken()), date, routeId)
suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse =
api.broadcastAuth(authHeader(requireToken()), BroadcastAuthBody(socketId, channelName))
suspend fun storePushToken(token: String) {
api.storePushToken(authHeader(requireToken()), PushTokenBody(token))
}
suspend fun storeRealtimeStatus(status: String, socketId: String? = null, error: String? = null) {
api.storeRealtimeStatus(authHeader(requireToken()), RealtimeStatusBody(status, socketId, error))
}
suspend fun deletePushToken() {
val token = tokenStore.read() ?: return
runCatching { api.deletePushToken(authHeader(token)) }
}
suspend fun uploadPhoto(
routeId: String,
uri: Uri,
source: String,
stage: String = "other",
metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
): PhotoUploadResponse {
val resolver = context.contentResolver
@@ -66,6 +129,7 @@ class DriverRepository(
clientRequestId.toPlainTextBody(),
contentSha256.toPlainTextBody(),
sourceBody,
stage.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
@@ -92,6 +156,7 @@ class DriverRepository(
upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
upload.stage.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
@@ -99,6 +164,32 @@ class DriverRepository(
)
}
suspend fun uploadQueuedDispatchSheetPhoto(upload: DispatchSheetUploadEntity): DispatchSheetUploadResponse {
val file = File(upload.localPath)
val body = file.readBytes().toRequestBody(upload.mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", file.name, body)
val metadataParts = PhotoUploadMetadata(
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
).toMultipartTextParts()
return api.uploadDispatchSheetPhoto(
authHeader(requireToken()),
upload.clientRequestId,
photo,
upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
metadataParts["locationAccuracyMeters"],
upload.replacePhotoId?.toPlainTextBody(),
)
}
suspend fun deletePhoto(photoId: String) {
api.deletePhoto(authHeader(requireToken()), photoId)
}
@@ -106,6 +197,7 @@ class DriverRepository(
suspend fun logout() {
val token = tokenStore.read()
if (token != null) {
runCatching { api.deletePushToken(authHeader(token)) }
runCatching { api.logout(authHeader(token)) }
}
tokenStore.clear()
@@ -3,10 +3,29 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody
import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
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
import retrofit2.http.Body
@@ -15,6 +34,7 @@ import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
@@ -29,18 +49,115 @@ interface MobileDriverApi {
@POST("mobile/driver/auth/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
@GET("mobile/driver/notification-preferences")
suspend fun notificationPreferences(
@Header("Authorization") authorization: String,
): NotificationPreferencesDto
@PUT("mobile/driver/notification-preferences")
suspend fun updateNotificationPreferences(
@Header("Authorization") authorization: String,
@Body body: NotificationPreferencesBody,
): NotificationPreferencesDto
@GET("mobile/driver/bootstrap")
suspend fun bootstrap(
@Header("Authorization") authorization: String,
@Query("date") date: String?,
): BootstrapResponse
@GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String,
): LeaveRequestTypesResponse
@GET("mobile/driver/leave-requests")
suspend fun leaveRequests(
@Header("Authorization") authorization: String,
): LeaveRequestListResponse
@POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest(
@Header("Authorization") authorization: String,
@Body body: CreateLeaveRequestBody,
): LeaveRequestResponse
@GET("mobile/driver/leave-requests/{id}")
suspend fun leaveRequest(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): LeaveRequestResponse
@POST("mobile/driver/leave-requests/{id}/cancel")
suspend fun cancelLeaveRequest(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Body body: CancelLeaveRequestBody,
): LeaveRequestResponse
@GET("mobile/driver/routes/{routeId}")
suspend fun route(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
): RouteResponse
@POST("mobile/driver/routes/{routeId}/complete")
suspend fun completeRoute(
@Header("Authorization") authorization: String,
@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,
@Query("date") date: String?,
@Query("routeId") routeId: String?,
): SyncStateResponse
@POST("mobile/driver/broadcasting/auth")
suspend fun broadcastAuth(
@Header("Authorization") authorization: String,
@Body body: BroadcastAuthBody,
): BroadcastAuthResponse
@PUT("mobile/driver/push-token")
suspend fun storePushToken(
@Header("Authorization") authorization: String,
@Body body: PushTokenBody,
): Map<String, Boolean>
@PUT("mobile/driver/realtime-status")
suspend fun storeRealtimeStatus(
@Header("Authorization") authorization: String,
@Body body: RealtimeStatusBody,
): Map<String, Boolean>
@DELETE("mobile/driver/push-token")
suspend fun deletePushToken(
@Header("Authorization") authorization: String,
): Map<String, Boolean>
@Multipart
@POST("mobile/driver/routes/{routeId}/photos")
suspend fun uploadPhoto(
@@ -51,12 +168,29 @@ 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?,
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
): PhotoUploadResponse
@Multipart
@POST("mobile/driver/dispatch-sheet-photos")
suspend fun uploadDispatchSheetPhoto(
@Header("Authorization") authorization: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Part photo: MultipartBody.Part,
@Part("clientRequestId") clientRequestId: RequestBody,
@Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody,
@Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
@Part("replacePhotoId") replacePhotoId: RequestBody?,
): DispatchSheetUploadResponse
@DELETE("mobile/driver/photos/{photoId}")
suspend fun deletePhoto(
@Header("Authorization") authorization: String,
@@ -46,6 +46,10 @@ data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
)
data class DriverAppSettingsDto(
@@ -56,6 +60,44 @@ data class DriverAppSettingsDto(
val maxPastDays: Int,
val autoRefreshSeconds: Int,
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null,
)
data class LeaveRequestsConfigDto(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
)
data class DispatchSheetReminderDto(
val enabled: Boolean = false,
val dueToday: Boolean = false,
val workDate: String? = null,
val reason: String? = null,
val availableUntil: String? = null,
val status: String = "not_required",
val photo: DispatchSheetPhotoDto? = null,
val canUpload: Boolean = false,
)
data class DispatchSheetPhotoDto(
val id: String,
val driverId: String,
val workDate: String?,
val clientRequestId: String? = null,
val contentSha256: String? = null,
val source: String,
val mimeType: String?,
val size: Long,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val createdAt: String?,
)
data class DriverSessionDto(
@@ -63,16 +105,125 @@ data class DriverSessionDto(
val lastSyncAt: String,
)
data class NotificationPreferencesDto(
val notifyNewRoutes: Boolean = false,
)
data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
)
data class RoutesBucketDto(
val today: List<DriverRouteDto> = emptyList(),
)
data class RouteResponse(
val route: DriverRouteDto,
val syncState: SyncStateResponse? = null,
)
data class CompleteRouteResponse(
val ok: Boolean,
val alreadyCompleted: Boolean,
val message: String?,
val completedAt: String?,
val route: DriverRouteDto,
)
data class SyncStateResponse(
val scopes: List<SyncScopeDto> = emptyList(),
)
data class SyncScopeDto(
val scope: String,
val date: String? = null,
val routeId: String? = null,
val version: Long,
val checksum: String,
val computedAt: String,
)
data class BroadcastAuthBody(
val socket_id: String,
val channel_name: String,
)
data class BroadcastAuthResponse(
val auth: String,
)
data class PushTokenBody(
val token: String,
val platform: String = "android",
)
data class NotificationPreferencesBody(
val notifyNewRoutes: Boolean,
)
data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(),
)
data class LeaveRequestResponse(
val data: DriverLeaveRequestDto,
)
data class LeaveRequestTypesResponse(
val data: List<LeaveRequestTypeDto> = emptyList(),
)
data class LeaveRequestTypeDto(
val value: String,
val label: String,
)
data class CreateLeaveRequestBody(
val dateFrom: String,
val dateTo: String,
val type: String,
val note: String?,
)
data class CancelLeaveRequestBody(
val comment: String? = null,
)
data class DriverLeaveRequestDto(
val id: String,
val driver: DriverDto? = null,
val dateFrom: String?,
val dateTo: String?,
val type: String,
val typeLabel: String? = null,
val note: String? = null,
val status: String,
val submittedAt: String? = null,
val decidedAt: String? = null,
val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(),
)
data class DriverLeaveRequestEventDto(
val id: String,
val action: String,
val fromStatus: String? = null,
val toStatus: String? = null,
val comment: String? = null,
val createdAt: String? = null,
)
data class RealtimeStatusBody(
val reverbStatus: String,
val socketId: String? = null,
val error: String? = null,
)
data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val startsAt: String,
val originName: String,
val destinationName: String,
@@ -81,12 +232,25 @@ 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?,
val completedAt: String? = null,
val completedByDriver: DriverDto? = null,
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(
@@ -109,6 +273,7 @@ data class RoutePhotoDto(
val clientRequestId: String? = null,
val contentSha256: String? = null,
val source: String,
val stage: String = "other",
val mimeType: String?,
val size: Long,
val url: String,
@@ -132,3 +297,65 @@ data class PhotoUploadReceiptDto(
val contentSha256: String,
val storedAt: String?,
)
data class StartRouteBody(
val clientActionId: String,
val loadingWeight: Double,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
data class FinishRouteBody(
val clientActionId: String,
val unloadingWeight: Double,
val occurredAt: String,
val photoClientRequestIds: List<String>,
)
data class RouteActionResponse(
val ok: Boolean,
val alreadyProcessed: Boolean,
val route: DriverRouteDto,
)
data class RoutePointBatchBody(
val clientBatchId: String,
val points: List<RoutePointDto>,
)
data class RoutePointDto(
val clientPointId: String,
val trip_id: String,
val driver_id: String,
val lat: Double,
val lon: Double,
val accuracy: Double,
val speed: Double?,
val bearing: Double?,
val timestamp: Long,
val battery: Int?,
val charging: Boolean,
)
data class RoutePointsResponse(
val ok: Boolean,
val stored: Int,
val progress: Double?,
val remainingDistanceMeters: Int?,
val arrived: Boolean,
val offRoute: Boolean,
val routeVersion: Long?,
)
data class DispatchSheetUploadResponse(
val photo: DispatchSheetPhotoDto,
val reminder: DispatchSheetReminderDto,
val receipt: DispatchSheetUploadReceiptDto,
)
data class DispatchSheetUploadReceiptDto(
val clientRequestId: String,
val serverPhotoId: String,
val contentSha256: String,
val storedAt: String?,
)
@@ -0,0 +1,36 @@
package pl.firmatpp.kierowca.data.sync
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface DriverCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertBootstrap(cache: DriverBootstrapCacheEntity)
@Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1")
suspend fun bootstrap(date: String): DriverBootstrapCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRoute(cache: DriverRouteCacheEntity)
@Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1")
suspend fun route(routeId: String): DriverRouteCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSyncState(state: DriverSyncStateEntity)
@Query("SELECT * FROM driver_sync_states WHERE scope = :scope AND ifnull(date, '-') = ifnull(:date, '-') AND ifnull(routeId, '-') = ifnull(:routeId, '-') LIMIT 1")
suspend fun syncState(scope: String, date: String?, routeId: String?): DriverSyncStateEntity?
@Query("DELETE FROM driver_bootstrap_cache")
suspend fun clearBootstrap()
@Query("DELETE FROM driver_route_cache")
suspend fun clearRoutes()
@Query("DELETE FROM driver_sync_states")
suspend fun clearSyncStates()
}
@@ -0,0 +1,49 @@
package pl.firmatpp.kierowca.data.sync
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "driver_bootstrap_cache")
data class DriverBootstrapCacheEntity(
@PrimaryKey val date: String,
val payloadJson: String,
val checksum: String?,
val version: Long?,
val syncedAtEpochMillis: Long,
)
@Entity(tableName = "driver_route_cache")
data class DriverRouteCacheEntity(
@PrimaryKey val routeId: String,
val payloadJson: String,
val checksum: String?,
val version: Long?,
val syncedAtEpochMillis: Long,
)
@Entity(
tableName = "driver_sync_states",
indices = [
Index(value = ["scope", "date", "routeId"], unique = true),
],
)
data class DriverSyncStateEntity(
@PrimaryKey val key: String,
val scope: String,
val date: String?,
val routeId: String?,
val checksum: String,
val version: Long,
val computedAt: String,
val syncedAtEpochMillis: Long,
)
data class CachedValue<T>(
val value: T,
val stale: Boolean,
val syncedAtEpochMillis: Long?,
)
fun syncStateKey(scope: String, date: String?, routeId: String?): String =
listOf(scope, date ?: "-", routeId ?: "-").joinToString(":")
@@ -0,0 +1,156 @@
package pl.firmatpp.kierowca.data.sync
import android.content.Context
import com.google.gson.Gson
import java.io.IOException
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncScopeDto
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.upload.DriverDatabase
import retrofit2.HttpException
class DriverSyncRepository(
context: Context,
private val repository: DriverRepository = DriverRepository(context),
private val dao: DriverCacheDao = DriverDatabase.get(context).driverCacheDao(),
private val gson: Gson = Gson(),
) {
suspend fun bootstrap(date: String?): CachedValue<BootstrapResponse> {
val requestedDate = date ?: currentDateFallback()
return runCatching {
val response = repository.bootstrap(date)
val selectedDate = response.driverAppSettings?.selectedDate ?: requestedDate
val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTES }
dao.upsertBootstrap(
DriverBootstrapCacheEntity(
date = selectedDate,
payloadJson = gson.toJson(response),
checksum = routeState?.checksum,
version = routeState?.version,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
cachedBootstrap(requestedDate, throwable)
}
}
suspend fun route(routeId: String): CachedValue<RouteResponse> =
runCatching {
val response = repository.route(routeId)
val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTE_DETAIL }
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = routeId,
payloadJson = gson.toJson(response),
checksum = routeState?.checksum,
version = routeState?.version,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
cachedRoute(routeId, throwable)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = route.id,
payloadJson = gson.toJson(RouteResponse(route = route)),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
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,
),
)
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
repository.syncState(date, routeId)
suspend fun shouldRefresh(scope: SyncScopeDto): Boolean {
val local = dao.syncState(scope.scope, scope.date, scope.routeId)
return local == null || local.checksum != scope.checksum || local.version < scope.version
}
suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope ->
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
checksum = scope.checksum,
version = scope.version,
computedAt = scope.computedAt,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
}
}
suspend fun clearCache() {
dao.clearBootstrap()
dao.clearRoutes()
dao.clearSyncStates()
}
private suspend fun cachedBootstrap(date: String, throwable: Throwable): CachedValue<BootstrapResponse> {
val cached = dao.bootstrap(date)
if (cached != null && throwable.isReadFallbackAllowed()) {
return CachedValue(
gson.fromJson(cached.payloadJson, BootstrapResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
throw throwable
}
private suspend fun cachedRoute(routeId: String, throwable: Throwable): CachedValue<RouteResponse> {
val cached = dao.route(routeId)
if (cached != null && throwable.isReadFallbackAllowed()) {
return CachedValue(
gson.fromJson(cached.payloadJson, RouteResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
throw throwable
}
private fun Throwable.isReadFallbackAllowed(): Boolean =
this is IOException || (this is HttpException && code() != 401 && code() != 403)
private fun currentDateFallback(): String = java.time.LocalDate.now().toString()
companion object {
const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
}
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.data.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
class NetworkMonitor(context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
fun current(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(current())
}
override fun onLost(network: Network) {
trySend(current())
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
trySend(current())
}
}
trySend(current())
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
@@ -0,0 +1,66 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface DispatchSheetUploadDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(upload: DispatchSheetUploadEntity)
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity?
@Query(
"""
SELECT * FROM dispatch_sheet_uploads
WHERE workDate = :workDate
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>>
@Query(
"""
UPDATE dispatch_sheet_uploads
SET status = :status,
progress = :progress,
lastError = :lastError,
attemptCount = attemptCount + :attemptIncrement,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun updateStatus(
clientRequestId: String,
status: String,
progress: Int,
lastError: String?,
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
@Query(
"""
UPDATE dispatch_sheet_uploads
SET status = 'CONFIRMED',
progress = 100,
lastError = NULL,
serverPhotoId = :serverPhotoId,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun markConfirmed(
clientRequestId: String,
serverPhotoId: String,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("DELETE FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
}
@@ -0,0 +1,37 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "dispatch_sheet_uploads",
indices = [
Index(value = ["workDate"]),
Index(value = ["status"]),
],
)
data class DispatchSheetUploadEntity(
@PrimaryKey val clientRequestId: String,
val workDate: String,
val replacePhotoId: String?,
val localPath: String,
val source: String,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val mimeType: String,
val size: Long,
val contentSha256: String,
val status: String = PhotoUploadStatus.Pending.storageValue,
val progress: Int = 0,
val attemptCount: Int = 0,
val lastError: String? = null,
val serverPhotoId: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
) {
val statusType: PhotoUploadStatus
get() = PhotoUploadStatus.fromStorage(status)
}
@@ -0,0 +1,102 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import android.net.Uri
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.Flow
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
class DispatchSheetUploadOutbox(
private val context: Context,
private val dao: DispatchSheetUploadDao = DriverDatabase.get(context).dispatchSheetUploadDao(),
private val workManager: WorkManager = WorkManager.getInstance(context),
) {
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>> =
dao.observeVisibleForWorkDate(workDate)
suspend fun enqueue(
workDate: String,
replacePhotoId: String?,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
): DispatchSheetUploadEntity {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
"image/png" -> "png"
"image/webp" -> "webp"
else -> "jpg"
}
val uploadDir = File(context.filesDir, "dispatch-sheet-upload-outbox").apply { mkdirs() }
val localFile = File(uploadDir, "$clientRequestId.$extension")
val sha256 = copyAndHash(uri, localFile)
val upload = DispatchSheetUploadEntity(
clientRequestId = clientRequestId,
workDate = workDate,
replacePhotoId = replacePhotoId,
localPath = localFile.absolutePath,
source = source,
takenAt = metadata.takenAt,
latitude = metadata.latitude,
longitude = metadata.longitude,
locationAccuracyMeters = metadata.locationAccuracyMeters,
mimeType = mimeType,
size = localFile.length(),
contentSha256 = sha256,
)
dao.upsert(upload)
enqueueWorker(clientRequestId)
return upload
}
suspend fun discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
DispatchSheetUploadWorker.uniqueWorkName(clientRequestId),
ExistingWorkPolicy.REPLACE,
request,
)
}
private fun copyAndHash(uri: Uri, target: File): String {
val digest = MessageDigest.getInstance("SHA-256")
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Nie można odczytać zdjęcia karty spedycyjnej." }
target.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read == -1) break
digest.update(buffer, 0, read)
output.write(buffer, 0, read)
}
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,10 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadReceiptDto
object DispatchSheetUploadReceiptVerifier {
fun matches(upload: DispatchSheetUploadEntity, receipt: DispatchSheetUploadReceiptDto): Boolean =
upload.clientRequestId == receipt.clientRequestId &&
upload.contentSha256.equals(receipt.contentSha256, ignoreCase = true) &&
receipt.serverPhotoId.isNotBlank()
}
@@ -0,0 +1,98 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DispatchSheetUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).dispatchSheetUploadDao()
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
if (!file.exists()) {
val message = "Lokalny plik zdjęcia karty spedycyjnej nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = PhotoUploadFailureDetails.local(message),
attemptIncrement = 0,
)
return Result.failure()
}
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Uploading.storageValue,
progress = 10,
lastError = null,
attemptIncrement = 1,
)
setProgress(workDataOf(KEY_PROGRESS to 10))
return runCatching {
setProgress(workDataOf(KEY_PROGRESS to 70))
val response = repository.uploadQueuedDispatchSheetPhoto(upload)
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Verifying.storageValue,
progress = 90,
lastError = null,
attemptIncrement = 0,
)
setProgress(workDataOf(KEY_PROGRESS to 90))
val receipt = response.receipt
if (!DispatchSheetUploadReceiptVerifier.matches(upload, receipt)) {
error("Serwer nie potwierdził zgodności zdjęcia karty spedycyjnej. Zdjęcie nie zostało zapisane.")
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "dispatch_sheet_upload_worker",
keys = mapOf(
"client_request_id" to clientRequestId,
"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) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = PhotoUploadFailureDetails.fromApiError(error),
attemptIncrement = 0,
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
fun uniqueWorkName(clientRequestId: String): String = "dispatch-sheet-upload-$clientRequestId"
}
}
@@ -1,29 +1,176 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.room.migration.Migration
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import pl.firmatpp.kierowca.data.sync.DriverBootstrapCacheEntity
import pl.firmatpp.kierowca.data.sync.DriverCacheDao
import pl.firmatpp.kierowca.data.sync.DriverRouteCacheEntity
import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
@Database(
entities = [PhotoUploadEntity::class],
version = 1,
entities = [
PhotoUploadEntity::class,
DispatchSheetUploadEntity::class,
RouteActionEntity::class,
RoutePointEntity::class,
DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
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 {
@Volatile
private var instance: DriverDatabase? = null
private val migration1To2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_bootstrap_cache (
date TEXT NOT NULL PRIMARY KEY,
payloadJson TEXT NOT NULL,
checksum TEXT,
version INTEGER,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_route_cache (
routeId TEXT NOT NULL PRIMARY KEY,
payloadJson TEXT NOT NULL,
checksum TEXT,
version INTEGER,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_sync_states (
key TEXT NOT NULL PRIMARY KEY,
scope TEXT NOT NULL,
date TEXT,
routeId TEXT,
checksum TEXT NOT NULL,
version INTEGER NOT NULL,
computedAt TEXT NOT NULL,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_driver_sync_states_scope_date_routeId ON driver_sync_states(scope, date, routeId)")
}
}
private val migration2To3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS dispatch_sheet_uploads (
clientRequestId TEXT NOT NULL PRIMARY KEY,
workDate TEXT NOT NULL,
replacePhotoId TEXT,
localPath TEXT NOT NULL,
source TEXT NOT NULL,
takenAt TEXT,
latitude REAL,
longitude REAL,
locationAccuracyMeters REAL,
mimeType TEXT NOT NULL,
size INTEGER NOT NULL,
contentSha256 TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
serverPhotoId TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_workDate ON dispatch_sheet_uploads(workDate)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_status ON dispatch_sheet_uploads(status)")
}
}
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",
).build().also { instance = it }
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5).build().also { instance = it }
}
}
}
@@ -27,12 +27,21 @@ interface PhotoUploadDao {
@Query(
"""
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status = 'CONFIRMED'
AND clientRequestId IN (:clientRequestIds)
WHERE status != 'CONFIRMED'
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun confirmedForServerPhotos(routeId: String, clientRequestIds: List<String>): List<PhotoUploadEntity>
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>>
@Query(
"""
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status = 'CONFIRMED'
""",
)
suspend fun confirmedForRoute(routeId: String): List<PhotoUploadEntity>
@Query(
"""
@@ -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?,
@@ -0,0 +1,39 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
object PhotoUploadFailureDetails {
fun fromApiError(error: ApiError): String {
val type = when (error.kind) {
ApiErrorKind.Network -> "błąd komunikacji"
ApiErrorKind.Server,
ApiErrorKind.RateLimited,
ApiErrorKind.Validation,
ApiErrorKind.Forbidden,
ApiErrorKind.Auth,
ApiErrorKind.Conflict,
-> "błąd serwera"
ApiErrorKind.Unknown -> "błąd aplikacji"
}
return buildString {
appendLine("Typ: $type")
appendLine("Komunikat: ${error.message}")
error.statusCode?.let { appendLine("HTTP: $it") }
error.code?.takeIf { it.isNotBlank() }?.let { appendLine("Kod: $it") }
error.responseBody
?.takeIf { it.isNotBlank() }
?.let {
appendLine("Odpowiedź serwera:")
append(it.take(MAX_RESPONSE_CHARS))
if (it.length > MAX_RESPONSE_CHARS) append("...")
}
}.trim()
}
fun local(message: String): String =
"Typ: błąd aplikacji\nKomunikat: $message"
private const val MAX_RESPONSE_CHARS = 1200
}
@@ -25,7 +25,10 @@ class PhotoUploadOutbox(
fun observeVisibleForRoute(routeId: String): Flow<List<PhotoUploadEntity>> =
dao.observeVisibleForRoute(routeId)
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata): PhotoUploadEntity {
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads()
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) {
@@ -41,6 +44,7 @@ class PhotoUploadOutbox(
routeId = routeId,
localPath = localFile.absolutePath,
source = source,
stage = stage,
takenAt = metadata.takenAt,
latitude = metadata.latitude,
longitude = metadata.longitude,
@@ -69,11 +73,15 @@ class PhotoUploadOutbox(
enqueueWorker(clientRequestId)
}
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct()
if (confirmedRequestIds.isEmpty()) return
suspend fun discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
dao.confirmedForServerPhotos(route.id, confirmedRequestIds).forEach { upload ->
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
dao.confirmedForRoute(route.id).forEach { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class PhotoUploadWorker(
appContext: Context,
@@ -22,11 +23,12 @@ class PhotoUploadWorker(
val file = File(upload.localPath)
if (!file.exists()) {
val message = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.",
lastError = PhotoUploadFailureDetails.local(message),
attemptIncrement = 0,
)
return Result.failure()
@@ -63,12 +65,24 @@ class PhotoUploadWorker(
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "photo_upload_worker",
keys = mapOf(
"client_request_id" to clientRequestId,
"route_id" to upload.routeId,
"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) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = error.message,
lastError = PhotoUploadFailureDetails.fromApiError(error),
attemptIncrement = 0,
)
@@ -0,0 +1,53 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface RouteActionDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(action: RouteActionEntity)
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', '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')
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
}
private fun enqueueWorker(clientActionId: String) {
val request = OneTimeWorkRequestBuilder<RouteActionWorker>()
.setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
RouteActionWorker.uniqueWorkName(clientActionId),
ExistingWorkPolicy.REPLACE,
request,
)
}
}
@@ -0,0 +1,132 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.google.gson.Gson
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverSyncWorker
class RouteActionWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val database = DriverDatabase.get(appContext)
private val dao = database.routeActionDao()
private val photoDao = database.photoUploadDao()
private val repository = DriverRepository(appContext)
private val syncRepository = DriverSyncRepository(appContext, repository)
private val gson = Gson()
override suspend fun doWork(): Result {
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure()
val photoClientRequestIds = photoClientRequestIds(action)
val waitingForPhotos = photoClientRequestIds
.mapNotNull { photoDao.find(it) }
.any { it.status != PhotoUploadStatus.Confirmed.storageValue }
if (waitingForPhotos) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.WaitingForPhotos,
lastError = "Czekam na wysłanie zdjęć etapu.",
attemptIncrement = 0,
)
return Result.retry()
}
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Syncing,
lastError = null,
attemptIncrement = 1,
)
return runCatching {
val response = sendAction(action, photoClientRequestIds)
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Confirmed,
lastError = null,
attemptIncrement = 0,
)
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "route_action_worker",
keys = mapOf(
"client_action_id" to clientActionId,
"route_id" to action.routeId,
"route_action" to action.action,
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable
else -> RouteActionStatus.FailedPermanent
}
dao.updateStatus(
clientActionId = clientActionId,
status = status,
lastError = error.message,
attemptIncrement = 0,
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
}
private suspend fun sendAction(action: RouteActionEntity, photoClientRequestIds: List<String>): RouteActionResponse =
when (action.action) {
RouteActionType.Start -> repository.startRoute(
routeId = action.routeId,
body = StartRouteBody(
clientActionId = action.clientActionId,
loadingWeight = action.weight,
occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds,
),
)
RouteActionType.Finish -> repository.finishRoute(
routeId = action.routeId,
body = FinishRouteBody(
clientActionId = action.clientActionId,
unloadingWeight = action.weight,
occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds,
),
)
else -> error("Nieznana akcja kursu: ${action.action}")
}
private fun photoClientRequestIds(action: RouteActionEntity): List<String> =
runCatching {
gson.fromJson(action.photoClientRequestIdsJson, Array<String>::class.java)?.toList().orEmpty()
}.getOrDefault(emptyList())
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
companion object {
const val KEY_CLIENT_ACTION_ID = "clientActionId"
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
}
}
@@ -0,0 +1,50 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface RoutePointDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(point: RoutePointEntity)
@Query(
"""
SELECT * FROM route_points
WHERE routeId = :routeId
AND status != 'CONFIRMED'
ORDER BY timestamp ASC
LIMIT :limit
""",
)
suspend fun pendingForRoute(routeId: String, limit: Int): List<RoutePointEntity>
@Query(
"""
SELECT COUNT(*) FROM route_points
WHERE routeId = :routeId
AND status != 'CONFIRMED'
""",
)
suspend fun pendingCount(routeId: String): Int
@Query(
"""
UPDATE route_points
SET status = :status,
lastError = :lastError,
attemptCount = attemptCount + :attemptIncrement,
updatedAtEpochMillis = :updatedAt
WHERE clientPointId IN (:clientPointIds)
""",
)
suspend fun updateStatus(
clientPointIds: List<String>,
status: String,
lastError: String?,
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
}
@@ -0,0 +1,40 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
object RoutePointStatus {
const val Pending = "PENDING"
const val Syncing = "SYNCING"
const val Confirmed = "CONFIRMED"
const val FailedRetryable = "FAILED_RETRYABLE"
const val FailedPermanent = "FAILED_PERMANENT"
}
@Entity(
tableName = "route_points",
indices = [
Index(value = ["routeId"]),
Index(value = ["status"]),
Index(value = ["routeId", "status"]),
],
)
data class RoutePointEntity(
@PrimaryKey val clientPointId: String,
val routeId: String,
val driverId: String,
val lat: Double,
val lon: Double,
val accuracy: Double,
val speed: Double?,
val bearing: Double?,
val timestamp: Long,
val battery: Int?,
val charging: Boolean,
val status: String = RoutePointStatus.Pending,
val attemptCount: Int = 0,
val lastError: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
)
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import android.location.Location
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import java.util.UUID
import java.util.concurrent.TimeUnit
class RoutePointOutbox(
private val context: Context,
private val dao: RoutePointDao = DriverDatabase.get(context).routePointDao(),
private val workManager: WorkManager = WorkManager.getInstance(context),
) {
suspend fun enqueue(
routeId: String,
driverId: String,
location: Location,
battery: Int?,
charging: Boolean,
uploadDelaySeconds: Long,
) {
dao.upsert(
RoutePointEntity(
clientPointId = UUID.randomUUID().toString(),
routeId = routeId,
driverId = driverId,
lat = location.latitude,
lon = location.longitude,
accuracy = if (location.hasAccuracy()) location.accuracy.toDouble() else 0.0,
speed = if (location.hasSpeed()) location.speed.toDouble() else null,
bearing = if (location.hasBearing()) location.bearing.toDouble() else null,
timestamp = ((location.time.takeIf { it > 0L } ?: System.currentTimeMillis()) / 1000L),
battery = battery,
charging = charging,
),
)
enqueueWorker(routeId, uploadDelaySeconds)
}
fun enqueueWorker(routeId: String, delaySeconds: Long = 20L) {
val request = OneTimeWorkRequestBuilder<RoutePointWorker>()
.setInputData(workDataOf(RoutePointWorker.KEY_ROUTE_ID to routeId))
.setInitialDelay(delaySeconds.coerceAtLeast(0L), TimeUnit.SECONDS)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
RoutePointWorker.uniqueWorkName(routeId),
ExistingWorkPolicy.KEEP,
request,
)
}
}
@@ -0,0 +1,90 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import java.util.UUID
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService
class RoutePointWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).routePointDao()
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val routeId = inputData.getString(KEY_ROUTE_ID) ?: return Result.failure()
val points = dao.pendingForRoute(routeId, BATCH_LIMIT)
if (points.isEmpty()) return Result.success()
val pointIds = points.map { it.clientPointId }
dao.updateStatus(pointIds, RoutePointStatus.Syncing, null, attemptIncrement = 1)
return runCatching {
val response = repository.storeRoutePoints(
routeId = routeId,
body = RoutePointBatchBody(
clientBatchId = UUID.randomUUID().toString(),
points = points.map { it.toDto() },
),
)
dao.updateStatus(pointIds, RoutePointStatus.Confirmed, null, attemptIncrement = 0)
if (response.arrived) {
ActiveRouteTrackingService.markArrived(applicationContext, routeId)
}
if (dao.pendingCount(routeId) > 0) {
RoutePointOutbox(applicationContext).enqueueWorker(routeId, delaySeconds = 1L)
}
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "route_point_worker",
keys = mapOf(
"route_id" to routeId,
"batch_size" to points.size,
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
val status = if (error.retryable) RoutePointStatus.FailedRetryable else RoutePointStatus.FailedPermanent
dao.updateStatus(pointIds, status, error.message, attemptIncrement = 0)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
}
private fun RoutePointEntity.toDto(): RoutePointDto =
RoutePointDto(
clientPointId = clientPointId,
trip_id = routeId,
driver_id = driverId,
lat = lat,
lon = lon,
accuracy = accuracy,
speed = speed,
bearing = bearing,
timestamp = timestamp,
battery = battery,
charging = charging,
)
companion object {
const val KEY_ROUTE_ID = "routeId"
private const val BATCH_LIMIT = 100
fun uniqueWorkName(routeId: String): String = "route-point-sync-$routeId"
}
}
@@ -0,0 +1,75 @@
package pl.firmatpp.kierowca.diagnostics
import com.google.firebase.crashlytics.FirebaseCrashlytics
interface DiagnosticsSink {
fun log(message: String)
fun setUserId(userId: String)
fun setCustomKey(key: String, value: String)
fun recordException(throwable: Throwable)
}
object NoOpDiagnosticsSink : DiagnosticsSink {
override fun log(message: String) = Unit
override fun setUserId(userId: String) = Unit
override fun setCustomKey(key: String, value: String) = Unit
override fun recordException(throwable: Throwable) = Unit
}
class FirebaseCrashlyticsDiagnosticsSink(
private val crashlytics: FirebaseCrashlytics = FirebaseCrashlytics.getInstance(),
) : DiagnosticsSink {
override fun log(message: String) {
crashlytics.log(message)
}
override fun setUserId(userId: String) {
crashlytics.setUserId(userId)
}
override fun setCustomKey(key: String, value: String) {
crashlytics.setCustomKey(key, value)
}
override fun recordException(throwable: Throwable) {
crashlytics.recordException(throwable)
}
}
object AppDiagnostics {
@Volatile
private var sink: DiagnosticsSink = NoOpDiagnosticsSink
fun installFirebaseCrashlytics() {
installSink(FirebaseCrashlyticsDiagnosticsSink())
}
fun installSink(nextSink: DiagnosticsSink) {
sink = nextSink
}
fun setDriverId(driverId: String) {
sink.setUserId(driverId)
}
fun clearDriverId() {
sink.setUserId("")
}
fun log(message: String) {
sink.log(message)
}
fun reportNonFatal(
throwable: Throwable,
operation: String,
keys: Map<String, Any?> = emptyMap(),
) {
sink.setCustomKey("operation", operation)
keys.forEach { (key, value) ->
sink.setCustomKey(key, value?.toString().orEmpty())
}
sink.log("non_fatal: $operation: ${throwable.message ?: throwable::class.java.simpleName}")
sink.recordException(throwable)
}
}
@@ -0,0 +1,57 @@
package pl.firmatpp.kierowca.sync
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.DriverRepository
class DriverFirebaseMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
scope.launch {
val repository = DriverRepository(applicationContext)
if (repository.hasToken()) {
runCatching { repository.storePushToken(token) }
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
if (data["type"] == "driver_new_route_notification") {
NewRouteNotificationWorker.enqueue(
context = applicationContext,
routeId = data["routeId"],
date = data["date"],
)
return
}
if (data["type"] == "driver_leave_request_decision") {
LeaveRequestDecisionNotificationWorker.enqueue(
context = applicationContext,
leaveRequestId = data["leaveRequestId"],
title = data["title"],
body = data["body"],
)
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
return
}
if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
}
}
@@ -0,0 +1,430 @@
package pl.firmatpp.kierowca.sync
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
interface DriverLiveSyncGateway {
suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse
suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?)
}
class DriverRepositoryLiveSyncGateway(
private val repository: DriverRepository,
) : DriverLiveSyncGateway {
override suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse =
repository.broadcastAuth(socketId, channelName)
override suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?) {
repository.storeRealtimeStatus(status, socketId, error)
}
}
interface LiveWebSocketFactory {
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket
}
class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient,
) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket =
client.newWebSocket(Request.Builder().url(url).build(), listener)
}
private enum class LiveSyncConnectionState {
Stopped,
WaitingForNetwork,
Disconnected,
Connecting,
Subscribing,
Connected,
}
class DriverLiveSyncClient(
private val gateway: DriverLiveSyncGateway,
private val onConnected: () -> Unit,
private val onHint: (DriverSyncHint) -> Unit,
private val webSocketFactory: LiveWebSocketFactory = OkHttpLiveWebSocketFactory(defaultOkHttpClient()),
private val gson: Gson = Gson(),
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
private val reconnectDelaysMs: List<Long> = DEFAULT_RECONNECT_DELAYS_MS,
private val staleTimeoutMs: Long = STALE_TIMEOUT_MS,
) {
constructor(
repository: DriverRepository,
onConnected: () -> Unit,
onHint: (DriverSyncHint) -> Unit,
client: OkHttpClient = defaultOkHttpClient(),
gson: Gson = Gson(),
) : this(
gateway = DriverRepositoryLiveSyncGateway(repository),
onConnected = onConnected,
onHint = onHint,
webSocketFactory = OkHttpLiveWebSocketFactory(client),
gson = gson,
)
private companion object {
const val HEARTBEAT_INTERVAL_MS = 10_000L
const val STALE_TIMEOUT_MS = 30_000L
val DEFAULT_RECONNECT_DELAYS_MS = listOf(1_000L, 2_000L, 5_000L, 10_000L, 30_000L)
fun defaultOkHttpClient(): OkHttpClient =
OkHttpClient.Builder()
.pingInterval(15, TimeUnit.SECONDS)
.build()
}
private val lock = Any()
private var desiredActive: Boolean = false
private var networkAvailable: Boolean = true
private var state: LiveSyncConnectionState = LiveSyncConnectionState.Stopped
private var webSocket: WebSocket? = null
private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null
private var reconnectJob: Job? = null
private var heartbeatJob: Job? = null
private var staleWatchdogJob: Job? = null
private var reconnectAttempt: Int = 0
private var messageVersion: Long = 0
fun start(driverId: String, config: RealtimeConfigDto?) {
if (!isConfigUsable(config)) return
synchronized(lock) {
realtimeConfig = config
this.driverId = driverId
desiredActive = true
}
ensureConnected()
}
fun ensureConnected() {
val shouldConnect = synchronized(lock) {
desiredActive &&
networkAvailable &&
state !in setOf(
LiveSyncConnectionState.Connecting,
LiveSyncConnectionState.Subscribing,
LiveSyncConnectionState.Connected,
)
}
if (shouldConnect) connectNow()
}
fun onNetworkAvailable(available: Boolean) {
val shouldReconnect = synchronized(lock) {
networkAvailable = available
if (!available) {
reconnectJob?.cancel()
reconnectJob = null
closeSocketLocked("network_lost")
state = if (desiredActive) LiveSyncConnectionState.WaitingForNetwork else LiveSyncConnectionState.Stopped
false
} else {
desiredActive && state == LiveSyncConnectionState.WaitingForNetwork
}
}
if (available) {
reportRealtimeStatus("reconnecting", "network_available")
} else {
reportRealtimeStatus("disconnected", "network_lost")
}
if (shouldReconnect) connectNow(resetAttempt = true)
}
fun stop() {
synchronized(lock) {
desiredActive = false
reconnectJob?.cancel()
reconnectJob = null
closeSocketLocked("client_stop")
driverId = null
realtimeConfig = null
socketId = null
reconnectAttempt = 0
state = LiveSyncConnectionState.Stopped
}
reportRealtimeStatus("disconnected", "client_stop")
}
fun close() {
stop()
scope.cancel()
}
private fun connectNow(resetAttempt: Boolean = false) {
val wsUrl = synchronized(lock) {
val config = realtimeConfig ?: return
val id = driverId ?: return
if (!desiredActive || !networkAvailable || !isConfigUsable(config)) return
if (state == LiveSyncConnectionState.Connecting || state == LiveSyncConnectionState.Subscribing || state == LiveSyncConnectionState.Connected) return
if (resetAttempt) reconnectAttempt = 0
reconnectJob?.cancel()
reconnectJob = null
socketId = null
state = LiveSyncConnectionState.Connecting
val appKey = config.reverbAppKey.orEmpty()
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false"
}
val socket = webSocketFactory.newWebSocket(
wsUrl,
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
handleDisconnect(
status = "disconnected",
reason = reason.takeIf { it.isNotBlank() } ?: "closed",
socket = webSocket,
)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
AppDiagnostics.log("realtime_error: ${t.message ?: response?.message ?: "unknown"}")
handleDisconnect(
status = "error",
reason = t.message ?: response?.message ?: "failure",
socket = webSocket,
)
}
},
)
synchronized(lock) {
webSocket = socket
}
}
private fun handleMessage(socket: WebSocket, text: String) {
val isCurrentSocket = synchronized(lock) {
if (webSocket !== socket) {
false
} else {
messageVersion += 1
true
}
}
if (!isCurrentSocket) return
val root = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return
val event = root.string("event") ?: return
when (event) {
"pusher:ping" -> {
socket.send("""{"event":"pusher:pong","data":{}}""")
restartStaleWatchdogIfConnected()
}
"pusher:connection_established" -> {
val nextSocketId = root.dataObject()?.string("socket_id") ?: return
synchronized(lock) {
socketId = nextSocketId
state = LiveSyncConnectionState.Subscribing
}
subscribe(socket, nextSocketId)
}
"pusher_internal:subscription_succeeded" -> {
val channel = root.string("channel")
val id = synchronized(lock) { driverId } ?: return
if (channel == "private-driver-mobile.$id") {
synchronized(lock) {
reconnectAttempt = 0
state = LiveSyncConnectionState.Connected
}
reportRealtimeStatus("connected")
startHeartbeat()
startStaleWatchdog()
onConnected()
}
}
"DriverMobileSyncHint" -> {
restartStaleWatchdogIfConnected()
parseHint(root.dataObject())?.let(onHint)
}
else -> restartStaleWatchdogIfConnected()
}
}
private fun subscribe(socket: WebSocket, socketId: String) {
val id = synchronized(lock) { driverId } ?: return
val channel = "private-driver-mobile.$id"
scope.launch {
runCatching {
val auth = gateway.broadcastAuth(socketId, channel).auth
if (!isCurrentSocket(socket)) return@launch
val payload = mapOf(
"event" to "pusher:subscribe",
"data" to mapOf(
"channel" to channel,
"auth" to auth,
),
)
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
}.onFailure { throwable ->
AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}")
socket.close(1000, "subscription_failed")
handleDisconnect("error", "subscription_failed", socket)
}
}
}
private fun handleDisconnect(status: String, reason: String?, socket: WebSocket? = null) {
val shouldSchedule = synchronized(lock) {
if (socket != null && webSocket !== socket) return
stopHeartbeatLocked()
stopStaleWatchdogLocked()
webSocket = null
socketId = null
if (!desiredActive) {
state = LiveSyncConnectionState.Stopped
false
} else if (!networkAvailable) {
state = LiveSyncConnectionState.WaitingForNetwork
false
} else {
state = LiveSyncConnectionState.Disconnected
true
}
}
reportRealtimeStatus(status, reason)
if (shouldSchedule) scheduleReconnect()
}
private fun scheduleReconnect() {
val delayMs = synchronized(lock) {
if (!desiredActive || !networkAvailable || state == LiveSyncConnectionState.Stopped) return
if (reconnectJob?.isActive == true) return
val delay = reconnectDelaysMs.getOrElse(reconnectAttempt) { reconnectDelaysMs.last() }
reconnectAttempt += 1
delay
}
reconnectJob = scope.launch {
delay(delayMs)
connectNow()
}
}
private fun startHeartbeat() {
synchronized(lock) {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (true) {
reportRealtimeStatus("connected")
delay(HEARTBEAT_INTERVAL_MS)
}
}
}
}
private fun startStaleWatchdog() {
synchronized(lock) {
stopStaleWatchdogLocked()
val observedVersion = messageVersion
staleWatchdogJob = scope.launch {
delay(staleTimeoutMs)
val socketToClose = synchronized(lock) {
if (state == LiveSyncConnectionState.Connected && messageVersion == observedVersion) {
webSocket
} else {
null
}
}
if (socketToClose != null) {
socketToClose.close(1001, "stale_connection")
handleDisconnect("error", "stale_connection", socketToClose)
ensureConnected()
}
}
}
}
private fun restartStaleWatchdogIfConnected() {
val connected = synchronized(lock) { state == LiveSyncConnectionState.Connected }
if (connected) startStaleWatchdog()
}
private fun isCurrentSocket(socket: WebSocket): Boolean =
synchronized(lock) { webSocket === socket }
private fun closeSocketLocked(reason: String) {
stopHeartbeatLocked()
stopStaleWatchdogLocked()
webSocket?.close(1000, reason)
webSocket = null
socketId = null
}
private fun stopHeartbeatLocked() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun stopStaleWatchdogLocked() {
staleWatchdogJob?.cancel()
staleWatchdogJob = null
}
private fun reportRealtimeStatus(status: String, error: String? = null) {
scope.launch {
runCatching {
gateway.storeRealtimeStatus(status, socketId, error)
}
}
}
private fun isConfigUsable(config: RealtimeConfigDto?): Boolean =
config?.reverbEnabled == true &&
!config.reverbAppKey.isNullOrBlank() &&
!config.reverbWsBaseUrl.isNullOrBlank()
private fun parseHint(data: JsonObject?): DriverSyncHint? {
if (data == null || data.string("type") != "driver_sync_hint") return null
return DriverSyncHint(
scope = data.string("scope") ?: return null,
date = data.string("date"),
routeId = data.string("routeId"),
version = data.string("version")?.toLongOrNull() ?: data.get("version")?.asLong ?: return null,
checksum = data.string("checksum") ?: return null,
)
}
private fun JsonObject.dataObject(): JsonObject? {
val data = get("data") ?: return null
return if (data.isJsonObject) data.asJsonObject else runCatching { JsonParser.parseString(data.asString).asJsonObject }.getOrNull()
}
private fun JsonObject.string(name: String): String? =
get(name)?.takeIf { !it.isJsonNull }?.asString
}
@@ -0,0 +1,21 @@
package pl.firmatpp.kierowca.sync
import pl.firmatpp.kierowca.data.model.SyncScopeDto
data class DriverSyncHint(
val scope: String,
val date: String?,
val routeId: String?,
val version: Long,
val checksum: String,
) {
fun asScope(): SyncScopeDto =
SyncScopeDto(
scope = scope,
date = date,
routeId = routeId,
version = version,
checksum = checksum,
computedAt = "",
)
}
@@ -0,0 +1,99 @@
package pl.firmatpp.kierowca.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val syncRepository = DriverSyncRepository(appContext)
override suspend fun doWork(): Result =
runCatching {
val date = inputData.getString(KEY_DATE)
val routeId = inputData.getString(KEY_ROUTE_ID)
val response = syncRepository.fetchSyncState(date, routeId)
var refreshed = false
response.scopes.forEach { scope ->
if (syncRepository.shouldRefresh(scope)) {
when (scope.scope) {
DriverSyncRepository.SCOPE_ROUTES -> {
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
DriverSyncRepository.SCOPE_ROUTE_DETAIL -> {
val id = scope.routeId ?: routeId
if (!id.isNullOrBlank()) {
syncRepository.route(id)
refreshed = true
}
}
DriverSyncRepository.SCOPE_SETTINGS,
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> {
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
}
}
}
if (!refreshed) {
syncRepository.saveSyncStates(response)
}
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "driver_sync_worker",
keys = mapOf(
"date" to inputData.getString(KEY_DATE),
"route_id" to inputData.getString(KEY_ROUTE_ID),
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
companion object {
private const val KEY_DATE = "date"
private const val KEY_ROUTE_ID = "routeId"
fun enqueue(context: Context, date: String?, routeId: String?) {
val request = OneTimeWorkRequestBuilder<DriverSyncWorker>()
.setInputData(workDataOf(KEY_DATE to date, KEY_ROUTE_ID to routeId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
listOf("driver-sync", date ?: "-", routeId ?: "-").joinToString("-"),
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -0,0 +1,110 @@
package pl.firmatpp.kierowca.sync
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import pl.firmatpp.kierowca.MainActivity
import pl.firmatpp.kierowca.R
class LeaveRequestDecisionNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val leaveRequestId = inputData.getString(KEY_LEAVE_REQUEST_ID)?.takeIf(String::isNotBlank)
?: return Result.success()
if (!canShowNotifications(applicationContext)) {
return Result.success()
}
createChannel(applicationContext)
val title = inputData.getString(KEY_TITLE)?.takeIf(String::isNotBlank) ?: "Decyzja w sprawie urlopu"
val body = inputData.getString(KEY_BODY)?.takeIf(String::isNotBlank) ?: "Status wniosku urlopowego został zmieniony."
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_LEAVE_REQUEST_ID, leaveRequestId)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
leaveRequestId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
return Result.success()
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"Wnioski urlopowe",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Powiadomienia o decyzjach w sprawie wniosków urlopowych."
}
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
}
private fun canShowNotifications(context: Context): Boolean {
val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
companion object {
private const val CHANNEL_ID = "leave_request_decisions"
private const val KEY_LEAVE_REQUEST_ID = "leaveRequestId"
private const val KEY_TITLE = "title"
private const val KEY_BODY = "body"
fun enqueue(context: Context, leaveRequestId: String?, title: String?, body: String?) {
if (leaveRequestId.isNullOrBlank()) return
val request = OneTimeWorkRequestBuilder<LeaveRequestDecisionNotificationWorker>()
.setInputData(
workDataOf(
KEY_LEAVE_REQUEST_ID to leaveRequestId,
KEY_TITLE to title,
KEY_BODY to body,
),
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"driver-leave-request-decision-$leaveRequestId",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.sync
import java.time.LocalDate
import java.util.Locale
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
import pl.firmatpp.kierowca.data.model.DriverRouteDto
object NewRouteNotificationFormatter {
private val polishLocale = Locale("pl", "PL")
private val monthFormatter = DateTimeFormatter.ofPattern("d MMMM", polishLocale)
fun title(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val label = humanDate(routeDate, today) ?: return "Nowy kurs"
return "Nowy kurs: $label"
}
fun collapsedText(route: DriverRouteDto): String =
listOf(route.originName, route.destinationName)
.filter(String::isNotBlank)
.joinToString("")
.ifBlank { route.relationLabel }
.let { relation ->
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode
if (contract.isNullOrBlank()) relation else "$relation · $contract"
}
fun expandedText(route: DriverRouteDto): String {
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode ?: "Brak nazwy kontraktu"
return listOf(
"Skąd: ${route.originName.ifBlank { "Nie podano" }}",
"Dokąd: ${route.destinationName.ifBlank { "Nie podano" }}",
"Kontrakt: $contract",
).joinToString("\n")
}
private fun humanDate(routeDate: String?, today: LocalDate): String? {
val date = runCatching { routeDate?.let(LocalDate::parse) }.getOrNull() ?: return null
return when {
date == today -> "dzisiaj"
date == today.plusDays(1) -> "jutro"
date.isAfter(today.plusDays(1)) && !date.isAfter(today.plusDays(7)) ->
"w ${weekdayAccusative(date.dayOfWeek)}"
else -> date.format(monthFormatter).lowercase(polishLocale)
}
}
private fun weekdayAccusative(dayOfWeek: DayOfWeek): String =
when (dayOfWeek) {
DayOfWeek.MONDAY -> "poniedziałek"
DayOfWeek.TUESDAY -> "wtorek"
DayOfWeek.WEDNESDAY -> "środę"
DayOfWeek.THURSDAY -> "czwartek"
DayOfWeek.FRIDAY -> "piątek"
DayOfWeek.SATURDAY -> "sobotę"
DayOfWeek.SUNDAY -> "niedzielę"
}
}
@@ -0,0 +1,137 @@
package pl.firmatpp.kierowca.sync
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.MainActivity
import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException
class NewRouteNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
return runCatching {
val route = repository.route(routeId).route
if (!canShowNotifications(applicationContext)) {
return@runCatching Result.success()
}
createChannel(applicationContext)
val contentTitle = NewRouteNotificationFormatter.title(route.routeDate ?: inputData.getString(KEY_DATE))
val contentText = NewRouteNotificationFormatter.collapsedText(route)
val expandedText = NewRouteNotificationFormatter.expandedText(route)
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_ROUTE_ID, route.id)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
route.id.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(contentTitle)
.setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedText))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
Result.success()
}.getOrElse { throwable ->
if (throwable is HttpException && throwable.code() == 404) {
Result.success()
} else {
val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "new_route_notification_worker",
keys = mapOf(
"route_id" to routeId,
"api_error_kind" to error.kind.name,
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
),
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
}
}
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"Nowe kursy",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Powiadomienia o nowych kursach dodanych przez spedytora."
}
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
}
private fun canShowNotifications(context: Context): Boolean {
val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
companion object {
private const val CHANNEL_ID = "new_routes"
private const val KEY_ROUTE_ID = "routeId"
private const val KEY_DATE = "date"
fun enqueue(context: Context, routeId: String?, date: String?) {
if (routeId.isNullOrBlank()) return
val request = OneTimeWorkRequestBuilder<NewRouteNotificationWorker>()
.setInputData(workDataOf(KEY_ROUTE_ID to routeId, KEY_DATE to date))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"driver-new-route-notification-$routeId",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -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
@@ -0,0 +1,55 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
data class LeaveRequestsConfig(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
)
object DriverLeaveRequestUiRules {
fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean =
config?.enabled == true && config.types.isNotEmpty()
fun statusLabel(status: String): String =
when (status) {
"pending" -> "Oczekuje"
"approved" -> "Zatwierdzony"
"rejected" -> "Odrzucony"
"cancel_requested" -> "Anulowanie do decyzji"
"cancelled" -> "Anulowany"
"revoked" -> "Cofnięto decyzję"
else -> "Nieznany status"
}
fun eventActionLabel(action: String): String =
when (action) {
"submitted" -> "Wniosek złożony"
"approved" -> "Wniosek zatwierdzony"
"rejected" -> "Wniosek odrzucony"
"revoked" -> "Decyzja cofnięta"
"cancelled_by_driver" -> "Wniosek anulowany przez kierowcę"
"cancellation_requested" -> "Kierowca poprosił o anulowanie"
"cancellation_approved" -> "Anulowanie zatwierdzone"
"cancellation_rejected" -> "Anulowanie odrzucone"
else -> "Aktualizacja wniosku"
}
fun typeLabel(type: String): String =
when (type) {
"URLOP" -> "Urlop"
"CHOROBOWE" -> "Chorobowe"
"SZKOLENIE" -> "Szkolenie"
"WOLNE" -> "Dzień wolny"
"INNE" -> "Inne"
else -> type
}
fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean {
if (status == "pending") return true
if (status != "approved") return false
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today)
}
}
@@ -0,0 +1,69 @@
package pl.firmatpp.kierowca.ui
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
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,
)
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
var projectedRoute = route
var loadingWeightPending = false
var unloadingWeightPending = false
visible
.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",
)
loadingWeightPending = true
}
RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
unloadingWeight = action.weight,
trackingStatus = "finished",
completedAt = action.occurredAt,
)
unloadingWeightPending = true
}
}
}
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
@@ -1,9 +1,358 @@
package pl.firmatpp.kierowca.ui
import java.io.File
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
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,
)
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 canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status == "W TRAKCIE"
fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean {
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == 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("Rozpocznij kurs", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Zakończ kurs", 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.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie."
else -> "Zmiana zapisana w telefonie. Wyślemy ją po odzyskaniu internetu."
}
},
isConflict = isConflict,
)
}
fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): List<RouteFlowStepUi> {
val startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
val loadingState = 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", "Załadunek", loadingState, routeFlowStateLabel(loadingState)),
RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState)),
)
}
fun routePhotosForStage(photos: List<RoutePhotoDto>, stage: String): List<RoutePhotoDto> {
val normalized = normalizedRoutePhotoStage(stage)
return photos.filter { normalizedRoutePhotoStage(it.stage) == normalized }
}
fun normalizedRoutePhotoStage(stage: String?): String =
when (stage) {
"loading", "unloading" -> stage
else -> "other"
}
fun activeRouteUploadIntervalSeconds(
isMoving: Boolean,
isUrbanOrTurning: Boolean = false,
isNearDestination: Boolean = false,
isHighwayStable: Boolean = false,
isLikelyOffRoute: Boolean = false,
): Int =
when {
isLikelyOffRoute -> 3
!isMoving -> 45
isUrbanOrTurning || isNearDestination -> 5
isHighwayStable -> 20
else -> 20
}
fun activeRouteNotificationTarget(arrived: Boolean): DriverScreen =
if (arrived) DriverScreen.FinishRoute else DriverScreen.Detail
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)
fun routeDateChipLabel(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val date = routeDate
?.takeIf { it.isNotBlank() }
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: return ""
val fallback = date.format(shortDateFormatter)
val days = ChronoUnit.DAYS.between(today, date)
val human = when {
days == 0L -> "dzisiaj"
days == 1L -> "jutro"
days in 2L..7L -> polishWeekdayPhrase(date.dayOfWeek)
else -> null
}
return human?.let { "$it · $fallback" } ?: fallback
}
private fun polishWeekdayPhrase(day: DayOfWeek): String =
when (day) {
DayOfWeek.MONDAY -> "w poniedziałek"
DayOfWeek.TUESDAY -> "we wtorek"
DayOfWeek.WEDNESDAY -> "w środę"
DayOfWeek.THURSDAY -> "w czwartek"
DayOfWeek.FRIDAY -> "w piątek"
DayOfWeek.SATURDAY -> "w sobotę"
DayOfWeek.SUNDAY -> "w niedzielę"
}
fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2
fun shouldShowPhotoPreviewLoadingState(hasPhoto: Boolean, isLoading: Boolean, isError: Boolean): Boolean =
hasPhoto && isLoading && !isError
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
screenHeightDp < 740 -> 150
else -> 190
}
fun appBrandHeaderHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 92
screenHeightDp < 740 -> 108
else -> 124
}
fun bottomNavigationHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 72
screenHeightDp < 740 -> 78
else -> 82
}
fun screenHorizontalPaddingDp(screenWidthDp: Int): Int =
when {
screenWidthDp < 360 -> 14
screenWidthDp < 420 -> 16
else -> 20
}
fun phonePrefixWidthDp(screenWidthDp: Int): Int =
when {
screenWidthDp < 360 -> 70
screenWidthDp < 420 -> 82
else -> 94
}
fun routeFactColumns(screenWidthDp: Int): Int =
if (screenWidthDp < 360) 1 else 2
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
fun queuedPhotoUploadCount(statuses: List<String>): Int =
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Long): Long =
parseIsoOffsetEpochMillis(createdAt)
?: parseIsoOffsetEpochMillis(takenAt)
?: fallback
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean =
status == "FAILED_RETRYABLE"
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
if (upload.localPath.isBlank()) return null
return RoutePhotoDto(
id = upload.clientRequestId,
routeId = upload.routeId,
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(),
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
createdAt = null,
canDelete = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null,
deleteUntil = null,
)
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when {
hasQueuedUpload -> "Wysyłam zdjęcie..."
reminder?.status == "uploaded" -> "Popraw"
else -> "Zrób zdjęcie"
}
fun offlineStaleBannerMessage(isOnline: Boolean, isStale: Boolean, syncLabel: String): String? =
when {
isOnline && !isStale -> null
!isOnline && syncLabel == "brak zapisanej synchronizacji" ->
"Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie."
!isOnline -> "Brak połączenia z serwerem. Dane z $syncLabel mogą być nieaktualne."
else -> "Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel."
}
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
@@ -4,22 +4,40 @@ import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
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
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.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 }
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, StartRoute, FinishRoute, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest }
data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing,
@@ -35,23 +53,132 @@ data class DriverUiState(
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
val autoRefreshSeconds: Int = 60,
val allowGalleryUploads: Boolean = true,
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,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
val selectedLeaveRequest: DriverLeaveRequestDto? = null,
val leaveRequestDateFrom: String = LocalDate.now().toString(),
val leaveRequestDateTo: String = LocalDate.now().toString(),
val leaveRequestType: String = "URLOP",
val leaveRequestNote: String = "",
val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false,
val imageAuthHeader: String? = null,
val isOnline: Boolean = true,
val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null,
val routeDayLiveUpdateMessage: String? = null,
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)
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)
return copy(
isOnline = if (apiError.kind == ApiErrorKind.Network) false else isOnline,
isStale = if (apiError.kind == ApiErrorKind.Network && lastSuccessfulSyncAtEpochMillis != null) true else isStale,
error = apiError.message,
)
}
internal fun DriverUiState.withLoadedLeaveRequests(
requests: List<DriverLeaveRequestDto>,
navigateToList: Boolean,
): DriverUiState {
val refreshedSelectedRequest = selectedLeaveRequest?.let { selected ->
requests.firstOrNull { request -> request.id == selected.id } ?: selected
}
return copy(
screen = if (navigateToList) DriverScreen.LeaveRequests else screen,
leaveRequests = requests,
selectedLeaveRequest = refreshedSelectedRequest,
error = null,
)
}
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(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()
private val liveSyncClient = DriverLiveSyncClient(
repository = repository,
onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
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
init {
viewModelScope.launch {
appPreferencesStore.themeMode.collect { themeMode ->
_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)
_state.update {
it.copy(
isOnline = online,
isStale = if (!online && it.lastSuccessfulSyncAtEpochMillis != null) true else it.isStale,
)
}
if (online && repository.hasToken()) {
liveSyncClient.ensureConnected()
refreshCurrentScopeFromSyncState()
}
}
}
viewModelScope.launch {
if (repository.hasToken()) {
refreshRoutes()
@@ -61,7 +188,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun requestOtp(phone: String) = runLoading {
fun requestOtp(phone: String) = runLoading("request_otp") {
val response = repository.requestOtp(phone)
otpAutoSubmitPolicy.reset()
_state.update {
@@ -75,8 +202,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun verifyOtp(code: String) = runLoading {
fun verifyOtp(code: String) = runLoading("verify_otp") {
val driver = repository.verifyOtp(_state.value.phone, code, android.os.Build.MODEL ?: "Android")
AppDiagnostics.setDriverId(driver.id)
_state.update { it.copy(driver = driver, imageAuthHeader = repository.imageAuthHeader()) }
refreshRoutes()
}
@@ -94,34 +222,74 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun selectRouteDate(date: String) = loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
fun onAppForegrounded() {
liveSyncClient.ensureConnected()
viewModelScope.launch { checkRemoteSyncState() }
if (_state.value.screen == DriverScreen.Routes) {
refreshRoutesSilently()
}
}
fun selectRouteDate(date: String) {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) }
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching {
val response = repository.bootstrap(date)
val cached = syncRepository.bootstrap(date)
val response = cached.value
val settings = response.driverAppSettings
AppDiagnostics.setDriverId(response.session.driver.id)
_state.update {
it.copy(
screen = if (navigateToRoutes) DriverScreen.Routes else it.screen,
driver = response.session.driver,
routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
leaveRequestsConfig = settings?.leaveRequests?.let { config ->
LeaveRequestsConfig(enabled = config.enabled, types = config.types)
},
screen = if (
settings?.leaveRequests?.enabled != true &&
it.screen in setOf(DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail, DriverScreen.AddLeaveRequest)
) DriverScreen.Routes else if (navigateToRoutes) DriverScreen.Routes else it.screen,
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId, response.realtime)
registerPushTokenIfAvailable(driverId)
}
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
}.onFailure { throwable ->
reportHandledException("load_routes", throwable, mapOf("date" to date))
_state.update {
it.copy(
screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Phone else it.screen,
error = throwable.message ?: "Wystapil blad.",
val apiError = ApiErrorMapper.map(throwable)
it.withApiError(throwable).copy(
screen = when {
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone
it.screen == DriverScreen.Initializing -> DriverScreen.Routes
else -> it.screen
},
)
}
}
@@ -132,32 +300,53 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun openRoute(routeId: String) = runLoading {
val response = repository.route(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, selectedRoute = response.route, error = null) }
_state.update {
it.copy(
screen = targetScreen,
selectedRoute = response.route,
routeStageWeightText = when (targetScreen) {
DriverScreen.StartRoute -> response.route.loadingWeight?.toString().orEmpty()
DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty()
else -> it.routeStageWeightText
},
feedback = null,
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
observePhotoUploads(routeId)
observeRouteActions(routeId)
}
fun refreshSelectedRoute() {
val routeId = _state.value.selectedRoute?.id ?: return
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null) }
_state.update { it.copy(refreshing = true, feedback = null, error = null) }
runCatching { repository.route(routeId) }
.onSuccess { response ->
runCatching { syncRepository.route(routeId) }
.onSuccess { cached ->
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { route -> if (route.id == routeId) response.route else route },
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") }
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(refreshing = false) }
@@ -168,22 +357,341 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
fun updateNewRouteNotifications(enabled: Boolean) {
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null, notificationPermissionDenied = false) }
runCatching { repository.updateNotificationPreferences(enabled) }
.onSuccess { preferences ->
_state.update {
it.copy(
notifyNewRoutes = preferences.notifyNewRoutes,
feedback = if (preferences.notifyNewRoutes) "Powiadomienia o nowych kursach są włączone." else "Powiadomienia o nowych kursach są wyłączone.",
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(refreshing = false) }
}
}
fun markNotificationPermissionDenied() {
_state.update {
it.copy(
notifyNewRoutes = false,
notificationPermissionDenied = true,
error = "Aby otrzymywać powiadomienia o nowych kursach, włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch {
runCatching { appPreferencesStore.setThemeMode(themeMode) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
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) }
}
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true, navigateToList = true)
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false, navigateToList = false)
}
private fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
_state.update { it.withLoadedLeaveRequests(requests, navigateToList) }
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false, refreshing = false) }
}
}
fun openLeaveRequest(id: String) {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.leaveRequest(id) }
.onSuccess { request ->
_state.update { it.copy(screen = DriverScreen.LeaveRequestDetail, selectedLeaveRequest = request, error = null) }
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false) }
}
}
fun openAddLeaveRequest() {
val config = _state.value.leaveRequestsConfig
if (!DriverLeaveRequestUiRules.isFeatureVisible(config)) return
val today = LocalDate.now().toString()
_state.update {
it.copy(
screen = DriverScreen.AddLeaveRequest,
leaveRequestDateFrom = today,
leaveRequestDateTo = today,
leaveRequestType = config?.types?.firstOrNull() ?: "URLOP",
leaveRequestNote = "",
error = null,
feedback = null,
)
}
}
fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) {
_state.update {
val nextFrom = dateFrom ?: it.leaveRequestDateFrom
val nextTo = dateTo ?: it.leaveRequestDateTo
it.copy(
leaveRequestDateFrom = nextFrom,
leaveRequestDateTo = if (nextTo < nextFrom) nextFrom else nextTo,
leaveRequestType = type ?: it.leaveRequestType,
leaveRequestNote = note ?: it.leaveRequestNote,
)
}
}
fun submitLeaveRequest() {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) {
_state.update { it.copy(error = "Data od nie może być z przeszłości.") }
return
}
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching {
repository.createLeaveRequest(
dateFrom = snapshot.leaveRequestDateFrom,
dateTo = snapshot.leaveRequestDateTo,
type = snapshot.leaveRequestType,
note = snapshot.leaveRequestNote.takeIf { it.isNotBlank() },
)
}.onSuccess { created ->
val requests = runCatching { repository.leaveRequests() }.getOrElse { listOf(created) }
_state.update {
it.copy(
screen = DriverScreen.LeaveRequestDetail,
selectedLeaveRequest = created,
leaveRequests = requests,
feedback = "Wniosek został wysłany do decyzji.",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
}
}
fun cancelSelectedLeaveRequest() {
val request = _state.value.selectedLeaveRequest ?: return
if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.cancelLeaveRequest(request.id) }
.onSuccess { updated ->
_state.update {
it.copy(
selectedLeaveRequest = updated,
leaveRequests = it.leaveRequests.map { existing -> if (existing.id == updated.id) updated else existing },
feedback = if (updated.status == "cancel_requested") "Anulowanie wysłane do decyzji." else "Wniosek został anulowany.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false) }
}
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other") {
val route = _state.value.selectedRoute ?: return
viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) }
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
}
}
fun openStartRoute() {
val route = _state.value.selectedRoute ?: return
_state.update {
it.copy(
screen = DriverScreen.StartRoute,
routeStageWeightText = route.loadingWeight?.toString().orEmpty(),
feedback = null,
error = null,
)
}
}
fun openFinishRoute() {
val route = _state.value.selectedRoute ?: return
_state.update {
it.copy(
screen = DriverScreen.FinishRoute,
routeStageWeightText = route.unloadingWeight?.toString().orEmpty(),
feedback = null,
error = null,
)
}
}
fun updateRouteStageWeight(value: String) {
val normalized = value.filter { it.isDigit() || it == ',' || it == '.' }.take(8)
_state.update { it.copy(routeStageWeightText = normalized) }
}
fun submitStartRoute() {
submitRouteStage(stage = "loading")
}
fun submitFinishRoute() {
submitRouteStage(stage = "unloading")
}
private fun submitRouteStage(stage: String) {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
val driver = snapshot.driver
val weight = snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull()
val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
if (
weight == null ||
!canSubmitRouteStageForm(
weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
) {
_state.update { it.copy(error = "Podaj wagę i dodaj co najmniej jedno zdjęcie.") }
return
}
val photoClientRequestIds = (stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId })
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (photoClientRequestIds.isEmpty()) {
_state.update { it.copy(error = "Zdjęcie musi mieć lokalny identyfikator synchronizacji.") }
return
}
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching {
val updatedRoute = when (stage) {
"loading" -> {
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds)
route.copy(
status = "W TRAKCIE",
loadingWeight = weight,
trackingStatus = "active",
)
}
"unloading" -> {
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds)
route.copy(
status = "ZAKOŃCZONA",
unloadingWeight = weight,
trackingStatus = "finished",
completedAt = Instant.now().toString(),
)
}
else -> route
}
syncRepository.cacheConfirmedRoute(updatedRoute, snapshot.selectedDate)
_state.update {
it.copy(
screen = DriverScreen.Detail,
selectedRoute = updatedRoute,
routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item },
routeStageWeightText = "",
feedback = if (stage == "loading") {
"Kurs rozpoczęty lokalnie. Synchronizacja pójdzie w tle."
} else {
"Kurs zakończony lokalnie. Synchronizacja pójdzie w tle."
},
error = null,
)
}
if (stage == "loading" && driver != null) {
ActiveRouteTrackingService.start(
context = getApplication(),
route = updatedRoute,
driver = driver,
progressEnabled = snapshot.routeProgressNotificationEnabled,
)
}
if (stage == "unloading") {
ActiveRouteTrackingService.stop(getApplication())
}
}.onFailure { throwable ->
reportHandledException("submit_route_stage", throwable, mapOf("route_id" to route.id, "stage" to stage))
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
}
}
fun uploadDispatchSheetPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val reminder = _state.value.dispatchSheetReminder ?: return
val workDate = reminder.workDate ?: return
if (!reminder.canUpload) return
viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching {
dispatchSheetUploadOutbox.enqueue(
workDate = workDate,
replacePhotoId = reminder.photo?.id,
uri = uri,
source = source,
metadata = metadata,
)
}.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
}
}
fun retryPhotoUpload(upload: PhotoUploadEntity) {
viewModelScope.launch {
runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
}
}
@@ -194,7 +702,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + photo.id, error = null) }
runCatching {
repository.deletePhoto(photo.id)
val response = repository.route(route.id)
val response = syncRepository.route(route.id).value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
@@ -204,23 +712,92 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - photo.id) }
}
}
fun deleteConfirmedUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
runCatching {
repository.deletePhoto(serverPhotoId)
photoUploadOutbox.discard(upload.clientRequestId)
val response = syncRepository.route(route.id).value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { item -> if (item.id == route.id) response.route else item },
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) }
}
}
fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return
if (_state.value.completingRoute || !_state.value.isOnline) return
viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
runCatching {
val completed = repository.completeRoute(route.id).route
syncRepository.cacheConfirmedRoute(completed, _state.value.selectedDate)
val refreshed = runCatching { syncRepository.route(route.id) }
.getOrNull()
?.takeUnless { it.stale }
?.value
?.route
?: completed
photoUploadOutbox.discardConfirmedServerPhotos(refreshed)
_state.update {
it.copy(
selectedRoute = refreshed,
routes = it.routes.map { item -> if (item.id == route.id) refreshed else item },
feedback = "Kurs zakończony",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.withApiError(throwable).copy(feedback = null) }
}
_state.update { it.copy(completingRoute = false) }
}
}
fun openPhoto(photo: RoutePhotoDto) {
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
}
fun openRouteFromNotification(routeId: String?, target: String? = null) {
if (routeId.isNullOrBlank()) return
val targetScreen = if (target == "finish") DriverScreen.FinishRoute else DriverScreen.Detail
openRouteWithTarget(routeId, targetScreen)
}
fun back() {
_state.update {
when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> {
photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList())
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)
@@ -229,18 +806,29 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun logout() = runLoading {
fun logout() = runLoading("logout") {
photoUploadsJob?.cancel()
routeActionsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop()
repository.logout()
syncRepository.clearCache()
pushTokenRegisteredForDriverId = null
AppDiagnostics.clearDriverId()
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
}
private fun runLoading(block: suspend () -> Unit) {
private fun runLoading(
operation: String,
keys: Map<String, Any?> = emptyMap(),
block: suspend () -> Unit,
) {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { block() }
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
reportHandledException(operation, throwable, keys)
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
}
@@ -254,4 +842,140 @@ 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()) {
_state.update { it.copy(dispatchSheetUploads = emptyList()) }
return
}
dispatchSheetUploadsJob = viewModelScope.launch {
dispatchSheetUploadOutbox.observeVisibleForWorkDate(workDate).collect { uploads ->
_state.update { it.copy(dispatchSheetUploads = uploads) }
}
}
}
private fun refreshCurrentScopeFromSyncState() {
val snapshot = _state.value
if (snapshot.screen == DriverScreen.Routes) {
refreshRoutesSilently()
return
}
if (snapshot.screen == DriverScreen.Detail) {
refreshSelectedRoute()
}
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
}
private suspend fun checkRemoteSyncState() {
val snapshot = _state.value
val routeId = snapshot.selectedRoute?.id
val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return
val needsRefresh = remote.scopes.any { syncRepository.shouldRefresh(it) }
if (needsRefresh) {
refreshCurrentScopeFromSyncState()
} else {
syncRepository.saveSyncStates(remote)
}
}
private suspend fun handleSyncHint(hint: DriverSyncHint) {
if (!syncRepository.shouldRefresh(hint.asScope())) return
val snapshot = _state.value
when (hint.scope) {
DriverSyncRepository.SCOPE_ROUTES -> {
if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) {
refreshRoutesSilently()
if (snapshot.screen == DriverScreen.Routes) {
_state.update {
it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.")
}
}
} else {
DriverSyncWorker.enqueue(getApplication(), hint.date, null)
}
}
DriverSyncRepository.SCOPE_ROUTE_DETAIL -> {
if (hint.routeId == snapshot.selectedRoute?.id) {
refreshSelectedRoute()
} else {
DriverSyncWorker.enqueue(getApplication(), hint.date, hint.routeId)
}
}
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
}
}
}
private fun registerPushTokenIfAvailable(driverId: String) {
if (pushTokenRegisteredForDriverId == driverId) return
runCatching {
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
viewModelScope.launch {
runCatching {
repository.storePushToken(token)
pushTokenRegisteredForDriverId = driverId
}.onFailure { throwable ->
reportHandledException("store_push_token", throwable, mapOf("driver_id" to driverId))
}
}
}.addOnFailureListener { throwable ->
reportHandledException("get_push_token", throwable, mapOf("driver_id" to driverId))
}
}.onFailure { throwable ->
reportHandledException("register_push_token", throwable, mapOf("driver_id" to driverId))
}
}
private fun reportHandledException(
operation: String,
throwable: Throwable,
keys: Map<String, Any?> = emptyMap(),
) {
val apiError = ApiErrorMapper.map(throwable)
if (!ApiErrorMapper.shouldReportNonFatal(throwable)) {
AppDiagnostics.log("network_failure: $operation: ${throwable.message ?: throwable::class.java.simpleName}")
return
}
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = operation,
keys = mapOf(
"screen" to _state.value.screen.name,
"api_error_kind" to apiError.kind.name,
"api_error_code" to apiError.code,
"api_status_code" to apiError.statusCode,
"retryable" to apiError.retryable,
) + keys,
)
}
override fun onCleared() {
liveSyncClient.close()
photoUploadsJob?.cancel()
routeActionsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
super.onCleared()
}
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
object PolishPhoneNumberVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val rawDigits = text.text.filter(Char::isDigit).take(9)
val formatted = rawDigits.chunked(3).joinToString(" ")
return TransformedText(
AnnotatedString(formatted),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val safeOffset = offset.coerceIn(0, rawDigits.length)
val spacesBeforeOffset = when {
safeOffset <= 3 -> 0
safeOffset <= 6 -> 1
else -> 2
}
return (safeOffset + spacesBeforeOffset).coerceAtMost(formatted.length)
}
override fun transformedToOriginal(offset: Int): Int {
val safeOffset = offset.coerceIn(0, formatted.length)
return formatted
.take(safeOffset)
.count(Char::isDigit)
.coerceAtMost(rawDigits.length)
}
},
)
}
}
@@ -0,0 +1,13 @@
package pl.firmatpp.kierowca.ui.theme
enum class AppThemeMode(val storedValue: String) {
Current("current"),
Material3("material3");
companion object {
val Default: AppThemeMode = Material3
fun fromStoredValue(value: String?): AppThemeMode =
entries.firstOrNull { it.storedValue == value } ?: Default
}
}
@@ -1,9 +1,20 @@
package pl.firmatpp.kierowca.ui.theme
import android.app.Activity
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
object TppColors {
val Forest = Color(0xFF154212)
@@ -18,7 +29,71 @@ object TppColors {
val Error = Color(0xFFBA1A1A)
}
private val TppScheme = lightColorScheme(
@Immutable
data class TppColorTokens(
val forest: Color,
val containerGreen: Color,
val logoLeaf: Color,
val navy: Color,
val surface: Color,
val panel: Color,
val ink: Color,
val muted: Color,
val outline: Color,
val error: Color,
val card: Color,
val successContainer: Color,
val successOutline: Color,
val warningContainer: Color,
val warningOutline: Color,
)
object TppTheme {
val colors: TppColorTokens
@Composable
@ReadOnlyComposable
get() = LocalTppColorTokens.current
}
private val CurrentColorTokens = TppColorTokens(
forest = TppColors.Forest,
containerGreen = TppColors.ContainerGreen,
logoLeaf = TppColors.LogoLeaf,
navy = TppColors.Navy,
surface = TppColors.Surface,
panel = TppColors.Panel,
ink = TppColors.Ink,
muted = TppColors.Muted,
outline = TppColors.Outline,
error = TppColors.Error,
card = Color.White,
successContainer = Color(0xFFEAF7EF),
successOutline = Color(0xFF9BD1AD),
warningContainer = Color(0xFFFFF7E6),
warningOutline = Color(0xFFE6B85C),
)
private val Material3ColorTokens = TppColorTokens(
forest = Color(0xFF226C2C),
containerGreen = Color(0xFF2F7D32),
logoLeaf = Color(0xFF5FA642),
navy = Color(0xFF225A86),
surface = Color(0xFFF8FBF6),
panel = Color(0xFFEAF1E6),
ink = Color(0xFF172018),
muted = Color(0xFF566252),
outline = Color(0xFFBAC8B5),
error = Color(0xFFBA1A1A),
card = Color(0xFFFFFFFF),
successContainer = Color(0xFFDFF4E2),
successOutline = Color(0xFF8EC69A),
warningContainer = Color(0xFFFFF1D6),
warningOutline = Color(0xFFE4B45B),
)
private val LocalTppColorTokens = staticCompositionLocalOf { CurrentColorTokens }
private val CurrentScheme = lightColorScheme(
primary = TppColors.Forest,
onPrimary = Color.White,
primaryContainer = TppColors.ContainerGreen,
@@ -33,10 +108,71 @@ private val TppScheme = lightColorScheme(
error = TppColors.Error,
)
private val Material3Scheme = lightColorScheme(
primary = Material3ColorTokens.forest,
onPrimary = Color.White,
primaryContainer = Material3ColorTokens.containerGreen,
onPrimaryContainer = Color.White,
secondary = Material3ColorTokens.navy,
onSecondary = Color.White,
secondaryContainer = Color(0xFFD2E7F7),
onSecondaryContainer = Color(0xFF071E30),
tertiary = Color(0xFF7A5D00),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFFFE08A),
background = Material3ColorTokens.surface,
onBackground = Material3ColorTokens.ink,
surface = Material3ColorTokens.surface,
onSurface = Material3ColorTokens.ink,
surfaceVariant = Material3ColorTokens.panel,
onSurfaceVariant = Material3ColorTokens.muted,
outline = Material3ColorTokens.outline,
outlineVariant = Color(0xFFD5DED1),
error = Material3ColorTokens.error,
)
private val CurrentShapes = Shapes(
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(2.dp),
small = androidx.compose.foundation.shape.RoundedCornerShape(4.dp),
medium = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
large = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
)
private val Material3Shapes = Shapes(
extraSmall = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
small = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
medium = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
large = androidx.compose.foundation.shape.RoundedCornerShape(28.dp),
extraLarge = androidx.compose.foundation.shape.RoundedCornerShape(32.dp),
)
@Composable
fun TppKierowcaTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = TppScheme,
content = content,
)
@Suppress("DEPRECATION")
fun TppKierowcaTheme(
themeMode: AppThemeMode = AppThemeMode.Default,
content: @Composable () -> Unit,
) {
val colorTokens = if (themeMode == AppThemeMode.Material3) Material3ColorTokens else CurrentColorTokens
val colorScheme = if (themeMode == AppThemeMode.Material3) Material3Scheme else CurrentScheme
val shapes = if (themeMode == AppThemeMode.Material3) Material3Shapes else CurrentShapes
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = colorTokens.surface.toArgb()
window.navigationBarColor = colorTokens.surface.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = true
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = true
}
}
CompositionLocalProvider(LocalTppColorTokens provides colorTokens) {
MaterialTheme(
colorScheme = colorScheme,
shapes = shapes,
content = content,
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -1,4 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F7FAF8" />
</shape>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0B192E</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ITSTransport" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.data
import java.io.IOException
import java.net.UnknownHostException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -16,6 +17,23 @@ class ApiErrorMapperTest {
assertEquals("Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", error.message)
}
@Test
fun mapsUnknownHostToActionableOfflineMessage() {
val error = ApiErrorMapper.map(UnknownHostException("api-intranet.firmatpp.pl"))
assertEquals(ApiErrorKind.Network, error.kind)
assertTrue(error.retryable)
assertEquals(
"Brak internetu lub połączenia z serwerem. Sprawdź zasięg i spróbuj ponownie.",
error.message,
)
}
@Test
fun doesNotReportExpectedNetworkFailuresAsNonFatal() {
assertFalse(ApiErrorMapper.shouldReportNonFatal(UnknownHostException("api-intranet.firmatpp.pl")))
}
@Test
fun mapsMobileApiProblemCodes() {
val error = ApiErrorMapper.mapProblem(
@@ -39,4 +57,16 @@ class ApiErrorMapperTest {
assertTrue(error.retryable)
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
}
@Test
fun keepsRawServerResponseForTroubleshooting() {
val body = """{"code":"PHOTO_SERVER_UNAVAILABLE","message":"Storage timeout","retryable":true}"""
val error = ApiErrorMapper.mapHttpStatus(503, body)
assertEquals(ApiErrorKind.Server, error.kind)
assertEquals(503, error.statusCode)
assertEquals("PHOTO_SERVER_UNAVAILABLE", error.code)
assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody)
}
}
@@ -0,0 +1,44 @@
package pl.firmatpp.kierowca.data
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import java.io.File
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
@OptIn(ExperimentalCoroutinesApi::class)
class AppPreferencesStoreTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun startsWithMaterial3ThemeMode() = runTest {
val store = preferencesStore("default.preferences_pb")
assertEquals(AppThemeMode.Material3, store.themeMode.first())
}
@Test
fun persistsSelectedThemeMode() = runTest {
val file = temporaryFolder.newFile("theme.preferences_pb")
val store = preferencesStore(file)
store.setThemeMode(AppThemeMode.Material3)
assertEquals(AppThemeMode.Material3, store.themeMode.first())
}
private fun TestScope.preferencesStore(fileName: String): AppPreferencesStore =
preferencesStore(temporaryFolder.newFile(fileName))
private fun TestScope.preferencesStore(file: File): AppPreferencesStore {
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
return AppPreferencesStore(dataStore)
}
}
@@ -0,0 +1,65 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadReceiptDto
class DispatchSheetUploadReceiptVerifierTest {
private val upload = DispatchSheetUploadEntity(
clientRequestId = "6f7a7d10-b7f7-41ab-8f5e-f1afc3e93736",
workDate = "2026-07-03",
replacePhotoId = "12",
localPath = "/tmp/dispatch-sheet.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
@Test
fun confirmsOnlyMatchingReceipt() {
assertTrue(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = upload.contentSha256.uppercase(),
storedAt = "2026-07-03T17:02:00+02:00",
),
),
)
}
@Test
fun rejectsDifferentRequestOrHash() {
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = "other",
serverPhotoId = "55",
contentSha256 = upload.contentSha256,
storedAt = null,
),
),
)
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
storedAt = null,
),
),
)
}
}
@@ -0,0 +1,44 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
class PhotoUploadFailureDetailsTest {
@Test
fun describesServerFailureWithStatusCodeBackendCodeAndResponseBody() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Server,
message = "Storage timeout",
retryable = true,
code = "PHOTO_STORAGE_FAILED",
statusCode = 503,
responseBody = """{"code":"PHOTO_STORAGE_FAILED","message":"Storage timeout"}""",
),
)
assertTrue(details.contains("Typ: błąd serwera"))
assertTrue(details.contains("HTTP: 503"))
assertTrue(details.contains("Kod: PHOTO_STORAGE_FAILED"))
assertTrue(details.contains("Odpowiedź serwera:"))
assertTrue(details.contains("Storage timeout"))
}
@Test
fun describesNetworkFailureWithoutHttpStatus() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem.",
retryable = true,
),
)
assertTrue(details.contains("Typ: błąd komunikacji"))
assertTrue(details.contains("Nie udało się połączyć z serwerem."))
assertFalse(details.contains("HTTP:"))
}
}
@@ -0,0 +1,71 @@
package pl.firmatpp.kierowca.diagnostics
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class AppDiagnosticsTest {
private val sink = RecordingDiagnosticsSink()
@After
fun tearDown() {
AppDiagnostics.installSink(NoOpDiagnosticsSink)
}
@Test
fun reportNonFatalAddsOperationContextBeforeRecordingException() {
AppDiagnostics.installSink(sink)
val throwable = IllegalStateException("upload failed")
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "photo_upload",
keys = mapOf("route_id" to "R-42", "retryable" to true),
)
assertEquals(
listOf(
"key:operation=photo_upload",
"key:route_id=R-42",
"key:retryable=true",
"log:non_fatal: photo_upload: upload failed",
"exception:IllegalStateException",
),
sink.events,
)
assertSame(throwable, sink.throwables.single())
}
@Test
fun clearDriverIdClearsPreviouslySetUserIdentifier() {
AppDiagnostics.installSink(sink)
AppDiagnostics.setDriverId("driver-7")
AppDiagnostics.clearDriverId()
assertEquals(listOf("user:driver-7", "user:"), sink.events)
}
private class RecordingDiagnosticsSink : DiagnosticsSink {
val events = mutableListOf<String>()
val throwables = mutableListOf<Throwable>()
override fun log(message: String) {
events += "log:$message"
}
override fun setUserId(userId: String) {
events += "user:$userId"
}
override fun setCustomKey(key: String, value: String) {
events += "key:$key=$value"
}
override fun recordException(throwable: Throwable) {
events += "exception:${throwable::class.simpleName}"
throwables += throwable
}
}
}
@@ -0,0 +1,217 @@
package pl.firmatpp.kierowca.sync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.runCurrent
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
@OptIn(ExperimentalCoroutinesApi::class)
class DriverLiveSyncClientTest {
private val config = RealtimeConfigDto(
reverbEnabled = true,
reverbAppKey = "app-key",
reverbWsBaseUrl = "wss://example.test/app",
)
@Test
fun reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().fail()
advanceTimeBy(999)
assertEquals(1, factory.sockets.size)
advanceTimeBy(1)
runCurrent()
assertEquals(2, factory.sockets.size)
factory.sockets.last().fail()
advanceTimeBy(1_999)
assertEquals(2, factory.sockets.size)
advanceTimeBy(1)
runCurrent()
assertEquals(3, factory.sockets.size)
}
@Test
fun pausesReconnectWhileOfflineAndReconnectsImmediatelyWhenNetworkReturns() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
client.onNetworkAvailable(false)
factory.sockets.single().fail()
advanceTimeBy(10_000)
assertEquals(1, factory.sockets.size)
assertTrue(factory.sockets.single().closed)
client.onNetworkAvailable(true)
advanceUntilIdle()
assertEquals(2, factory.sockets.size)
}
@Test
fun foregroundEnsureConnectedRestartsWhenSocketIsDisconnected() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().fail()
client.ensureConnected()
advanceUntilIdle()
assertEquals(2, factory.sockets.size)
}
@Test
fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest {
val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline"))
val factory = FakeWebSocketFactory()
val client = liveClient(gateway = gateway, factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
advanceUntilIdle()
advanceTimeBy(1_000)
runCurrent()
assertEquals(2, factory.sockets.size)
}
@Test
fun respondsToPusherPingWithPong() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().message("""{"event":"pusher:ping"}""")
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") })
}
@Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
advanceUntilIdle()
factory.sockets.single().message("""{"event":"pusher_internal:subscription_succeeded","channel":"private-driver-mobile.driver-1"}""")
advanceTimeBy(30_000)
runCurrent()
assertTrue(factory.sockets.first().closed)
assertEquals(2, factory.sockets.size)
}
@Test
fun ignoresCallbacksFromOldSocketAfterReconnect() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
val oldSocket = factory.sockets.single()
oldSocket.fail()
client.ensureConnected()
assertEquals(2, factory.sockets.size)
oldSocket.fail()
client.ensureConnected()
assertEquals(2, factory.sockets.size)
}
private fun liveClient(
gateway: FakeLiveSyncGateway = FakeLiveSyncGateway(),
factory: FakeWebSocketFactory,
scope: CoroutineScope,
): DriverLiveSyncClient =
DriverLiveSyncClient(
gateway = gateway,
onConnected = {},
onHint = {},
webSocketFactory = factory,
scope = scope,
reconnectDelaysMs = listOf(1_000L, 2_000L),
staleTimeoutMs = 30_000L,
)
private fun connectionEstablished(socketId: String): String =
"""{"event":"pusher:connection_established","data":"{\"socket_id\":\"$socketId\"}"}"""
private class FakeLiveSyncGateway(
private val authFailure: Throwable? = null,
) : DriverLiveSyncGateway {
override suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse {
authFailure?.let { throw it }
return BroadcastAuthResponse(auth = "auth-token")
}
override suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?) = Unit
}
private class FakeWebSocketFactory : LiveWebSocketFactory {
val sockets = mutableListOf<FakeWebSocket>()
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket {
val socket = FakeWebSocket(listener)
sockets += socket
return socket
}
}
private class FakeWebSocket(
private val listener: WebSocketListener,
) : WebSocket {
val sent = mutableListOf<String>()
var closed = false
fun fail() {
listener.onFailure(this, RuntimeException("boom"), null)
}
fun message(text: String) {
listener.onMessage(this, text)
}
override fun request(): Request = Request.Builder().url("wss://example.test/app").build()
override fun queueSize(): Long = 0L
override fun send(text: String): Boolean {
sent += text
return true
}
override fun send(bytes: ByteString): Boolean = true
override fun close(code: Int, reason: String?): Boolean {
closed = true
return true
}
override fun cancel() {
closed = true
}
}
}
@@ -0,0 +1,46 @@
package pl.firmatpp.kierowca.sync
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
class NewRouteNotificationFormatterTest {
@Test
fun formatsHumanRouteDates() {
val today = LocalDate.of(2026, 7, 2)
assertEquals("Nowy kurs: dzisiaj", NewRouteNotificationFormatter.title("2026-07-02", today))
assertEquals("Nowy kurs: jutro", NewRouteNotificationFormatter.title("2026-07-03", today))
assertEquals("Nowy kurs: w sobotę", NewRouteNotificationFormatter.title("2026-07-04", today))
assertEquals("Nowy kurs: 12 lipca", NewRouteNotificationFormatter.title("2026-07-12", today))
}
@Test
fun formatsCollapsedAndExpandedRouteText() {
val route = DriverRouteDto(
id = "42",
routeDate = "2026-07-03",
startsAt = "09:15",
originName = "TPP Baza Krakow",
destinationName = "Instalacja Odpadowa Slask",
contractorName = "TPP Baza Krakow",
contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA",
distanceKm = 91.4,
notes = null,
truck = null,
)
assertEquals(
"TPP Baza Krakow → Instalacja Odpadowa Slask · Odpady przemyslowe",
NewRouteNotificationFormatter.collapsedText(route),
)
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Skąd: TPP Baza Krakow"))
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Dokąd: Instalacja Odpadowa Slask"))
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Kontrakt: Odpady przemyslowe"))
}
}
@@ -0,0 +1,51 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverLeaveRequestUiRulesTest {
@Test
fun featureIsVisibleOnlyWhenServerConfigEnablesIt() {
assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(null))
assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = false, types = listOf("URLOP"))))
assertTrue(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = true, types = listOf("URLOP"))))
}
@Test
fun exposesPolishStatusLabels() {
assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Zatwierdzony", DriverLeaveRequestUiRules.statusLabel("approved"))
assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected"))
assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled"))
assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked"))
assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status"))
}
@Test
fun exposesPolishEventActionLabels() {
assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted"))
assertEquals("Wniosek zatwierdzony", DriverLeaveRequestUiRules.eventActionLabel("approved"))
assertEquals("Wniosek odrzucony", DriverLeaveRequestUiRules.eventActionLabel("rejected"))
assertEquals("Decyzja cofnięta", DriverLeaveRequestUiRules.eventActionLabel("revoked"))
assertEquals("Wniosek anulowany przez kierowcę", DriverLeaveRequestUiRules.eventActionLabel("cancelled_by_driver"))
assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested"))
assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved"))
assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected"))
assertEquals("Aktualizacja wniosku", DriverLeaveRequestUiRules.eventActionLabel("some_raw_action"))
}
@Test
fun driverCanCancelPendingAndFutureApprovedRequests() {
val tomorrow = LocalDate.now().plusDays(1).toString()
val yesterday = LocalDate.now().minusDays(1).toString()
assertTrue(DriverLeaveRequestUiRules.canCancel(status = "pending", dateFrom = yesterday))
assertTrue(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = tomorrow))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow))
}
}
@@ -0,0 +1,143 @@
package pl.firmatpp.kierowca.ui
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.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 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[2].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[0].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",
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")
}
}

Some files were not shown because too many files have changed in this diff Show More