Compare commits

...
9 Commits
41 changed files with 3117 additions and 321 deletions
+4 -2
View File
@@ -10,6 +10,7 @@ plugins {
if (file("google-services.json").exists()) { if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services") apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
} }
val keystorePropertiesFile = rootProject.file("keystore.properties") val keystorePropertiesFile = rootProject.file("keystore.properties")
@@ -33,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 36 versionCode = 43
versionName = "1.0.35" versionName = "1.0.41"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -123,6 +124,7 @@ dependencies {
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.coroutines.android) implementation(libs.coroutines.android)
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging) implementation(libs.firebase.messaging)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
+1
View File
@@ -10,6 +10,7 @@
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:name=".DriverApplication"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.TppKierowca"> android:theme="@style/Theme.TppKierowca">
@@ -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")
}
}
@@ -1,11 +1,12 @@
package pl.firmatpp.kierowca package pl.firmatpp.kierowca
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.activity.ComponentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel import pl.firmatpp.kierowca.ui.DriverViewModel
@@ -13,14 +14,22 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null) private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
setContent { setContent {
TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel() val viewModel: DriverViewModel = viewModel()
DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId) val state by viewModel.state.collectAsStateWithLifecycle()
TppKierowcaTheme(themeMode = state.themeMode) {
DriverApp(
viewModel = viewModel,
initialRouteId = notificationRouteId,
initialLeaveRequestId = notificationLeaveRequestId,
)
} }
} }
} }
@@ -29,9 +38,11 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
} }
companion object { companion object {
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID" const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
const val EXTRA_LEAVE_REQUEST_ID = "pl.firmatpp.kierowca.EXTRA_LEAVE_REQUEST_ID"
} }
} }
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.data package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException
import retrofit2.HttpException import retrofit2.HttpException
enum class ApiErrorKind { enum class ApiErrorKind {
@@ -26,6 +27,11 @@ data class ApiError(
object ApiErrorMapper { object ApiErrorMapper {
fun map(throwable: Throwable): ApiError = fun map(throwable: Throwable): ApiError =
when (throwable) { 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( is IOException -> ApiError(
kind = ApiErrorKind.Network, kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", message = "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.",
@@ -39,6 +45,9 @@ object ApiErrorMapper {
) )
} }
fun shouldReportNonFatal(throwable: Throwable): Boolean =
map(throwable).kind != ApiErrorKind.Network
fun mapHttpStatus(statusCode: Int, body: String?): ApiError { fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } 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) } val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
@@ -0,0 +1,30 @@
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.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")
val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue
}
}
}
@@ -14,7 +14,11 @@ import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse 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.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -25,6 +29,7 @@ import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverRepository( class DriverRepository(
@@ -49,6 +54,18 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse = suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId) 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) = suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId) api.completeRoute(authHeader(requireToken()), routeId)
@@ -131,6 +148,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) { suspend fun deletePhoto(photoId: String) {
api.deletePhoto(authHeader(requireToken()), photoId) api.deletePhoto(authHeader(requireToken()), photoId)
} }
@@ -5,7 +5,13 @@ import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse 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.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
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.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -55,6 +61,35 @@ interface MobileDriverApi {
@Query("date") date: String?, @Query("date") date: String?,
): BootstrapResponse ): 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}") @GET("mobile/driver/routes/{routeId}")
suspend fun route( suspend fun route(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -113,6 +148,22 @@ interface MobileDriverApi {
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?, @Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
): PhotoUploadResponse ): 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}") @DELETE("mobile/driver/photos/{photoId}")
suspend fun deletePhoto( suspend fun deletePhoto(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -46,6 +46,7 @@ data class BootstrapResponse(
val session: DriverSessionDto, val session: DriverSessionDto,
val routes: RoutesBucketDto, val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?, val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null, val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
@@ -61,6 +62,42 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?, val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?, val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: 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( data class DriverSessionDto(
@@ -126,6 +163,58 @@ data class NotificationPreferencesBody(
val notifyNewRoutes: Boolean, 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( data class RealtimeStatusBody(
val reverbStatus: String, val reverbStatus: String,
val socketId: String? = null, val socketId: String? = null,
@@ -196,3 +285,16 @@ data class PhotoUploadReceiptDto(
val contentSha256: String, val contentSha256: String,
val storedAt: String?, val storedAt: String?,
) )
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?,
)
@@ -150,5 +150,7 @@ class DriverSyncRepository(
const val SCOPE_ROUTES = "routes" const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail" const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings" const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
} }
} }
@@ -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"
}
}
@@ -14,15 +14,17 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
@Database( @Database(
entities = [ entities = [
PhotoUploadEntity::class, PhotoUploadEntity::class,
DispatchSheetUploadEntity::class,
DriverBootstrapCacheEntity::class, DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao abstract fun photoUploadDao(): PhotoUploadDao
abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao
abstract fun driverCacheDao(): DriverCacheDao abstract fun driverCacheDao(): DriverCacheDao
companion object { companion object {
@@ -71,13 +73,45 @@ abstract class DriverDatabase : RoomDatabase() {
} }
} }
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)")
}
}
fun get(context: Context): DriverDatabase = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
DriverDatabase::class.java, DriverDatabase::class.java,
"driver-local-outbox.db", "driver-local-outbox.db",
).addMigrations(migration1To2).build().also { instance = it } ).addMigrations(migration1To2, migration2To3).build().also { instance = it }
} }
} }
} }
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class PhotoUploadWorker( class PhotoUploadWorker(
appContext: Context, appContext: Context,
@@ -64,6 +65,18 @@ class PhotoUploadWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
val error = ApiErrorMapper.map(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 val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus( dao.updateStatus(
clientRequestId = clientRequestId, clientRequestId = clientRequestId,
@@ -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)
}
}
@@ -31,6 +31,21 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
return 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 if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue( DriverSyncWorker.enqueue(
@@ -3,10 +3,10 @@ package pl.firmatpp.kierowca.sync
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Job
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -16,73 +16,155 @@ import okhttp3.Request
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverLiveSyncClient( interface DriverLiveSyncGateway {
private val repository: DriverRepository, suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse
private val onConnected: () -> Unit, suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?)
private val onHint: (DriverSyncHint) -> Unit,
private val client: OkHttpClient = OkHttpClient(),
private val gson: Gson = Gson(),
) {
private companion object {
const val HEARTBEAT_INTERVAL_MS = 10_000L
} }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) class DriverRepositoryLiveSyncGateway(
private val started = AtomicBoolean(false) 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 webSocket: WebSocket? = null
private var driverId: String? = null private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null private var socketId: String? = null
private var reconnectJob: Job? = null
private var heartbeatJob: 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?) { fun start(driverId: String, config: RealtimeConfigDto?) {
val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return if (!isConfigUsable(config)) return
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
if (!config.reverbEnabled) return
synchronized(lock) {
realtimeConfig = config realtimeConfig = config
this.driverId = driverId this.driverId = driverId
if (!started.compareAndSet(false, true)) return desiredActive = true
}
val wsUrl = wsBaseUrl.trimEnd('/') + ensureConnected()
"/" + appKey +
"?protocol=7&client=android&version=1.0&flash=false"
webSocket = client.newWebSocket(
Request.Builder().url(wsUrl).build(),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { fun ensureConnected() {
stopHeartbeat() val shouldConnect = synchronized(lock) {
reportRealtimeStatus("disconnected", reason.takeIf { it.isNotBlank() }) desiredActive &&
started.set(false) networkAvailable &&
scheduleReconnect() state !in setOf(
} LiveSyncConnectionState.Connecting,
LiveSyncConnectionState.Subscribing,
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) { LiveSyncConnectionState.Connected,
stopHeartbeat()
reportRealtimeStatus("error", t.message ?: response?.message)
started.set(false)
scheduleReconnect()
}
},
) )
} }
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() { fun stop() {
started.set(false) synchronized(lock) {
stopHeartbeat() desiredActive = false
reportRealtimeStatus("disconnected", "client_stop") reconnectJob?.cancel()
webSocket?.close(1000, "logout") reconnectJob = null
webSocket = null closeSocketLocked("client_stop")
driverId = null driverId = null
realtimeConfig = null realtimeConfig = null
socketId = null socketId = null
reconnectAttempt = 0
state = LiveSyncConnectionState.Stopped
}
reportRealtimeStatus("disconnected", "client_stop")
} }
fun close() { fun close() {
@@ -90,35 +172,112 @@ class DriverLiveSyncClient(
scope.cancel() 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) { 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 root = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return
val event = root.string("event") ?: return val event = root.string("event") ?: return
when (event) { when (event) {
"pusher:ping" -> {
socket.send("""{"event":"pusher:pong","data":{}}""")
restartStaleWatchdogIfConnected()
}
"pusher:connection_established" -> { "pusher:connection_established" -> {
socketId = root.dataObject()?.string("socket_id") ?: return val nextSocketId = root.dataObject()?.string("socket_id") ?: return
subscribe(socket, socketId ?: return) synchronized(lock) {
socketId = nextSocketId
state = LiveSyncConnectionState.Subscribing
}
subscribe(socket, nextSocketId)
} }
"pusher_internal:subscription_succeeded" -> { "pusher_internal:subscription_succeeded" -> {
val channel = root.string("channel") val channel = root.string("channel")
val id = driverId ?: return val id = synchronized(lock) { driverId } ?: return
if (channel == "private-driver-mobile.$id") { if (channel == "private-driver-mobile.$id") {
synchronized(lock) {
reconnectAttempt = 0
state = LiveSyncConnectionState.Connected
}
reportRealtimeStatus("connected") reportRealtimeStatus("connected")
startHeartbeat() startHeartbeat()
startStaleWatchdog()
onConnected() onConnected()
} }
} }
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint) "DriverMobileSyncHint" -> {
restartStaleWatchdogIfConnected()
parseHint(root.dataObject())?.let(onHint)
}
else -> restartStaleWatchdogIfConnected()
} }
} }
private fun subscribe(socket: WebSocket, socketId: String) { private fun subscribe(socket: WebSocket, socketId: String) {
val id = driverId ?: return val id = synchronized(lock) { driverId } ?: return
val channel = "private-driver-mobile.$id" val channel = "private-driver-mobile.$id"
scope.launch { scope.launch {
runCatching { runCatching {
val auth = repository.broadcastAuth(socketId, channel).auth val auth = gateway.broadcastAuth(socketId, channel).auth
if (!isCurrentSocket(socket)) return@launch
val payload = mapOf( val payload = mapOf(
"event" to "pusher:subscribe", "event" to "pusher:subscribe",
"data" to mapOf( "data" to mapOf(
@@ -126,11 +285,129 @@ class DriverLiveSyncClient(
"auth" to auth, "auth" to auth,
), ),
) )
socket.send(gson.toJson(payload)) 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? { private fun parseHint(data: JsonObject?): DriverSyncHint? {
if (data == null || data.string("type") != "driver_sync_hint") return null if (data == null || data.string("type") != "driver_sync_hint") return null
@@ -150,37 +427,4 @@ class DriverLiveSyncClient(
private fun JsonObject.string(name: String): String? = private fun JsonObject.string(name: String): String? =
get(name)?.takeIf { !it.isJsonNull }?.asString get(name)?.takeIf { !it.isJsonNull }?.asString
private fun scheduleReconnect() {
val id = driverId ?: return
scope.launch {
delay(5_000)
if (!started.get() && driverId == id) {
start(id, realtimeConfig)
}
}
}
private fun startHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (started.get()) {
reportRealtimeStatus("connected")
delay(HEARTBEAT_INTERVAL_MS)
}
}
}
private fun stopHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun reportRealtimeStatus(status: String, error: String? = null) {
scope.launch {
runCatching {
repository.storeRealtimeStatus(status, socketId, error)
}
}
}
} }
@@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
class DriverSyncWorker( class DriverSyncWorker(
appContext: Context, appContext: Context,
@@ -42,6 +43,15 @@ class DriverSyncWorker(
refreshed = true 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
}
} }
} }
} }
@@ -53,6 +63,18 @@ class DriverSyncWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
val error = ApiErrorMapper.map(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() if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
} }
@@ -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,
)
}
}
}
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException import retrofit2.HttpException
class NewRouteNotificationWorker( class NewRouteNotificationWorker(
@@ -76,6 +77,17 @@ class NewRouteNotificationWorker(
Result.success() Result.success()
} else { } else {
val error = ApiErrorMapper.map(throwable) 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() if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
} }
} }
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)
}
}
@@ -7,6 +7,7 @@ import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -141,6 +142,25 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean = fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation !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? = private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let { value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
@@ -11,22 +11,29 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.AppPreferencesStore
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor 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.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto 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.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker import pl.firmatpp.kierowca.sync.DriverSyncWorker
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue } enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest }
data class DriverUiState( data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
@@ -50,6 +57,15 @@ data class DriverUiState(
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val 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 deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false, val completingRoute: Boolean = false,
val imageAuthHeader: String? = null, val imageAuthHeader: String? = null,
@@ -57,15 +73,27 @@ data class DriverUiState(
val isStale: Boolean = false, val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null, val lastSuccessfulSyncAtEpochMillis: Long? = null,
val routeDayLiveUpdateMessage: String? = null, val routeDayLiveUpdateMessage: String? = null,
val themeMode: AppThemeMode = AppThemeMode.Default,
val feedback: String? = null, val feedback: String? = null,
val error: String? = null, val error: String? = null,
) )
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,
)
}
class DriverViewModel(application: Application) : AndroidViewModel(application) { class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application) private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository) private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application) private val photoUploadOutbox = PhotoUploadOutbox(application)
private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true)) private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val liveSyncClient = DriverLiveSyncClient( private val liveSyncClient = DriverLiveSyncClient(
@@ -74,10 +102,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } }, onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } },
) )
private var photoUploadsJob: Job? = null private var photoUploadsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null private var pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
init { init {
viewModelScope.launch {
appPreferencesStore.themeMode.collect { themeMode ->
_state.update { it.copy(themeMode = themeMode) }
}
}
viewModelScope.launch { viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads -> photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) } _state.update { it.copy(queuedPhotoUploads = uploads) }
@@ -85,6 +119,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
viewModelScope.launch { viewModelScope.launch {
networkMonitor.isOnline.collect { online -> networkMonitor.isOnline.collect { online ->
liveSyncClient.onNetworkAvailable(online)
_state.update { _state.update {
it.copy( it.copy(
isOnline = online, isOnline = online,
@@ -92,6 +127,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
if (online && repository.hasToken()) { if (online && repository.hasToken()) {
liveSyncClient.ensureConnected()
refreshCurrentScopeFromSyncState() refreshCurrentScopeFromSyncState()
} }
} }
@@ -105,7 +141,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun requestOtp(phone: String) = runLoading { fun requestOtp(phone: String) = runLoading("request_otp") {
val response = repository.requestOtp(phone) val response = repository.requestOtp(phone)
otpAutoSubmitPolicy.reset() otpAutoSubmitPolicy.reset()
_state.update { _state.update {
@@ -119,8 +155,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") 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()) } _state.update { it.copy(driver = driver, imageAuthHeader = repository.imageAuthHeader()) }
refreshRoutes() refreshRoutes()
} }
@@ -138,6 +175,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun onAppForegrounded() {
liveSyncClient.ensureConnected()
viewModelScope.launch { checkRemoteSyncState() }
if (_state.value.screen == DriverScreen.Routes) {
refreshRoutesSilently()
}
}
fun selectRouteDate(date: String) { fun selectRouteDate(date: String) {
_state.update { it.copy(routeDayLiveUpdateMessage = null) } _state.update { it.copy(routeDayLiveUpdateMessage = null) }
loadRoutes(date = date, showLoading = true, navigateToRoutes = true) loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
@@ -155,11 +200,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val cached = syncRepository.bootstrap(date) val cached = syncRepository.bootstrap(date)
val response = cached.value val response = cached.value
val settings = response.driverAppSettings val settings = response.driverAppSettings
AppDiagnostics.setDriverId(response.session.driver.id)
_state.update { _state.update {
it.copy( it.copy(
screen = if (navigateToRoutes) DriverScreen.Routes else it.screen,
driver = response.session.driver, driver = response.session.driver,
routes = response.routes.today, routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate, selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate, minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate, maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
@@ -168,6 +214,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.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, notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(), imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
@@ -179,17 +232,17 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
liveSyncClient.start(driverId, response.realtime) liveSyncClient.start(driverId, response.realtime)
registerPushTokenIfAvailable(driverId) registerPushTokenIfAvailable(driverId)
} }
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
}.onFailure { throwable -> }.onFailure { throwable ->
reportHandledException("load_routes", throwable, mapOf("date" to date))
_state.update { _state.update {
val apiError = ApiErrorMapper.map(throwable) val apiError = ApiErrorMapper.map(throwable)
it.copy( it.withApiError(throwable).copy(
screen = when { screen = when {
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone
it.screen == DriverScreen.Initializing -> DriverScreen.Routes it.screen == DriverScreen.Initializing -> DriverScreen.Routes
else -> it.screen else -> it.screen
}, },
isStale = it.lastSuccessfulSyncAtEpochMillis != null,
error = apiError.message,
) )
} }
} }
@@ -200,7 +253,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun openRoute(routeId: String) = runLoading { fun openRoute(routeId: String) = runLoading("open_route", mapOf("route_id" to routeId)) {
val cached = syncRepository.route(routeId) val cached = syncRepository.route(routeId)
val response = cached.value val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route) photoUploadOutbox.discardConfirmedServerPhotos(response.route)
@@ -238,7 +291,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
.onFailure { throwable -> .onFailure { throwable ->
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") } _state.update { it.withApiError(throwable) }
} }
_state.update { it.copy(refreshing = false) } _state.update { it.copy(refreshing = false) }
@@ -263,7 +316,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
.onFailure { throwable -> .onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } _state.update { it.withApiError(throwable) }
} }
_state.update { it.copy(refreshing = false) } _state.update { it.copy(refreshing = false) }
} }
@@ -279,17 +332,175 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch {
runCatching { appPreferencesStore.setThemeMode(themeMode) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun openPhotoQueue() { fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
} }
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true)
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false)
}
private fun loadLeaveRequests(showLoading: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
_state.update {
it.copy(
screen = DriverScreen.LeaveRequests,
leaveRequests = requests,
selectedLeaveRequest = requests.firstOrNull { request -> request.id == it.selectedLeaveRequest?.id } ?: it.selectedLeaveRequest,
error = null,
)
}
}
.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()) { fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val route = _state.value.selectedRoute ?: return val route = _state.value.selectedRoute ?: return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(error = null) } _state.update { it.copy(error = null) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) } runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) }
.onFailure { throwable -> .onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } _state.update { it.withApiError(throwable) }
}
}
}
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) }
} }
} }
} }
@@ -298,7 +509,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch { viewModelScope.launch {
runCatching { photoUploadOutbox.retry(upload.clientRequestId) } runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
.onFailure { throwable -> .onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } _state.update { it.withApiError(throwable) }
} }
} }
} }
@@ -319,7 +530,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
}.onFailure { throwable -> }.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) } _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - photo.id) }
} }
@@ -344,7 +555,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
}.onFailure { throwable -> }.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } _state.update { it.withApiError(throwable) }
} }
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) } _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) }
} }
@@ -375,7 +586,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
}.onFailure { throwable -> }.onFailure { throwable ->
_state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) } _state.update { it.withApiError(throwable).copy(feedback = null) }
} }
_state.update { it.copy(completingRoute = false) } _state.update { it.copy(completingRoute = false) }
} }
@@ -395,6 +606,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) { when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.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 -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null) it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
@@ -406,21 +620,28 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun logout() = runLoading { fun logout() = runLoading("logout") {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop() liveSyncClient.stop()
repository.logout() repository.logout()
syncRepository.clearCache() syncRepository.clearCache()
pushTokenRegisteredForDriverId = null pushTokenRegisteredForDriverId = null
AppDiagnostics.clearDriverId()
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) } _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 { viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) } _state.update { it.copy(loading = true, error = null) }
runCatching { block() } runCatching { block() }
.onFailure { throwable -> .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) } _state.update { it.copy(loading = false) }
} }
@@ -435,6 +656,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
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() { private fun refreshCurrentScopeFromSyncState() {
val snapshot = _state.value val snapshot = _state.value
if (snapshot.screen == DriverScreen.Routes) { if (snapshot.screen == DriverScreen.Routes) {
@@ -444,6 +679,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (snapshot.screen == DriverScreen.Detail) { if (snapshot.screen == DriverScreen.Detail) {
refreshSelectedRoute() refreshSelectedRoute()
} }
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
} }
private suspend fun checkRemoteSyncState() { private suspend fun checkRemoteSyncState() {
@@ -484,6 +722,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
}
} }
} }
@@ -496,14 +740,44 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { runCatching {
repository.storePushToken(token) repository.storePushToken(token)
pushTokenRegisteredForDriverId = driverId 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() { override fun onCleared() {
liveSyncClient.close() liveSyncClient.close()
dispatchSheetUploadsJob?.cancel()
super.onCleared() super.onCleared()
} }
} }
@@ -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 package pl.firmatpp.kierowca.ui.theme
import android.app.Activity
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable 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.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 { object TppColors {
val Forest = Color(0xFF154212) val Forest = Color(0xFF154212)
@@ -18,7 +29,71 @@ object TppColors {
val Error = Color(0xFFBA1A1A) 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, primary = TppColors.Forest,
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = TppColors.ContainerGreen, primaryContainer = TppColors.ContainerGreen,
@@ -33,10 +108,71 @@ private val TppScheme = lightColorScheme(
error = TppColors.Error, 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 @Composable
fun TppKierowcaTheme(content: @Composable () -> Unit) { @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( MaterialTheme(
colorScheme = TppScheme, colorScheme = colorScheme,
shapes = shapes,
content = content, content = content,
) )
} }
}
+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 package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue 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) 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 @Test
fun mapsMobileApiProblemCodes() { fun mapsMobileApiProblemCodes() {
val error = ApiErrorMapper.mapProblem( val error = ApiErrorMapper.mapProblem(
@@ -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,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,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))
}
}
@@ -6,6 +6,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest { class DriverUiRulesTest {
@@ -190,6 +191,37 @@ class DriverUiRulesTest {
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false)) assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
} }
@Test
fun showsDispatchSheetCardOnlyWhenReminderIsDueToday() {
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "missing", dueToday = true)))
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "uploaded", dueToday = true)))
assertFalse(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "not_required", dueToday = false)))
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun explainsOfflineStateWithoutCachedSync() {
assertEquals(
"Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie.",
offlineStaleBannerMessage(isOnline = false, isStale = false, syncLabel = "brak zapisanej synchronizacji"),
)
}
@Test
fun explainsStaleCachedDataWhenOnline() {
assertEquals(
"Dane mogą być nieaktualne. Ostatnia synchronizacja: 12:30.",
offlineStaleBannerMessage(isOnline = true, isStale = true, syncLabel = "12:30"),
)
}
@Test
fun labelsDispatchSheetActionByStatusAndLocalUploadQueue() {
assertEquals("Zrób zdjęcie", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = false))
assertEquals("Wysyłam zdjęcie...", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = true))
assertEquals("Popraw", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "uploaded"), hasQueuedUpload = false))
}
private fun route(status: String): DriverRouteDto = private fun route(status: String): DriverRouteDto =
DriverRouteDto( DriverRouteDto(
id = "1", id = "1",
@@ -222,4 +254,16 @@ class DriverUiRulesTest {
status = status, status = status,
serverPhotoId = serverPhotoId, serverPhotoId = serverPhotoId,
) )
private fun dispatchReminder(status: String, dueToday: Boolean = true): DispatchSheetReminderDto =
DispatchSheetReminderDto(
enabled = dueToday,
dueToday = dueToday,
workDate = "2026-06-30",
reason = if (dueToday) "friday" else null,
availableUntil = "2026-06-30T23:59:59+02:00",
status = status,
photo = null,
canUpload = dueToday,
)
} }
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
class DriverUiStateTest { class DriverUiStateTest {
@Test @Test
@@ -12,4 +13,11 @@ class DriverUiStateTest {
assertEquals(DriverScreen.Initializing, state.screen) assertEquals(DriverScreen.Initializing, state.screen)
assertTrue(state.loading) assertTrue(state.loading)
} }
@Test
fun startsWithMaterial3ThemeMode() {
val state = DriverUiState()
assertEquals(AppThemeMode.Material3, state.themeMode)
}
} }
@@ -0,0 +1,23 @@
package pl.firmatpp.kierowca.ui.theme
import org.junit.Assert.assertEquals
import org.junit.Test
class AppThemeModeTest {
@Test
fun parsesStoredMaterial3Value() {
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue("material3"))
}
@Test
fun fallsBackToCurrentForUnknownStoredValue() {
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue("future-theme"))
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue(null))
}
@Test
fun storesStablePreferenceValues() {
assertEquals("current", AppThemeMode.Current.storedValue)
assertEquals("material3", AppThemeMode.Material3.storedValue)
}
}
+1
View File
@@ -3,4 +3,5 @@ plugins {
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) apply false alias(libs.plugins.google.services) apply false
alias(libs.plugins.firebase.crashlytics) apply false
} }
+3
View File
@@ -20,6 +20,7 @@ playServicesAuthApiPhone = "18.3.0"
junit = "4.13.2" junit = "4.13.2"
firebaseBom = "33.7.0" firebaseBom = "33.7.0"
googleServices = "4.4.2" googleServices = "4.4.2"
firebaseCrashlyticsPlugin = "3.0.7"
[libraries] [libraries]
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -55,6 +56,7 @@ retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref =
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -62,3 +64,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }