Compare commits

..
Author SHA1 Message Date
admin 321b260e3d Wydaj aplikację kierowcy 1.0.76 (129) 2026-07-30 12:08:10 +02:00
admin be3c885988 Dodaj seryjne zdjęcia załadunku i rozładunku 2026-07-30 12:07:05 +02:00
admin 802393962e Wydaj aplikację kierowcy 1.0.75 (128) 2026-07-30 02:04:03 +02:00
admin ac31308443 Ukrywaj wybrane salda w aplikacji kierowcy 2026-07-30 02:01:46 +02:00
admin af832dee93 Wydaj aplikację kierowcy 1.0.74 (127) 2026-07-30 01:44:37 +02:00
admin 8ca5f20a98 Rozszerz mobilną obsługę urlopów i nieobecności 2026-07-30 01:43:26 +02:00
admin 26b17ece26 Wydaj aplikację kierowcy 1.0.73 (126) 2026-07-30 00:26:21 +02:00
admin 2e7eb7e47b Uprość obsługę urlopów w aplikacji kierowcy 2026-07-30 00:24:39 +02:00
admin 062415a75d Wydaj aplikację kierowcy 1.0.72 (125) 2026-07-29 18:07:48 +02:00
admin ab68be4c1e Napraw pętlę uruchamiania aplikacji kierowcy 2026-07-29 18:06:24 +02:00
admin 513c1785ef Wydaj aplikację kierowcy 1.0.71 (124) 2026-07-24 13:40:46 +02:00
admin e70a53eca7 Dodaj potwierdzanie odczytów tachografu 2026-07-24 13:33:27 +02:00
admin c3004a3d2e Dodaj kartę przypomnienia o tachografie 2026-07-24 13:11:16 +02:00
admin fda4b462dc Wydaj aplikację kierowcy 1.0.70 (123) 2026-07-24 12:55:12 +02:00
admin 6a1a15ade9 Add route date calendar picker 2026-07-24 12:49:32 +02:00
admin ea44cf5d2e Wydaj aplikację kierowcy 1.0.69 (122) 2026-07-24 01:30:07 +02:00
admin 984feb170a Wydaj aplikację kierowcy 1.0.68 (121) 2026-07-23 15:14:50 +02:00
admin 5691c87e91 Dostosuj aplikację do Androida 16 2026-07-23 15:14:05 +02:00
admin 227e08a671 Wydaj aplikację kierowcy 1.0.67 (120) 2026-07-23 15:03:18 +02:00
admin 3bfb5f9788 Usuń nieaktualny konflikt zakończenia kursu 2026-07-23 15:02:21 +02:00
admin bf1cf3865a Napraw synchronizację statusu kursu kierowcy 2026-07-23 14:15:34 +02:00
admin 0c3bed6213 Dostosuj lokalizację kursu do ustawień 2026-07-23 12:51:26 +02:00
31 changed files with 4273 additions and 245 deletions
+1
View File
@@ -1,4 +1,5 @@
.gradle/
.kotlin/
build/
local.properties
app/google-services.json
+4 -4
View File
@@ -22,7 +22,7 @@ val keystoreProperties = Properties().apply {
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
compileSdk = 36
fun configValue(name: String, default: String): String =
providers.gradleProperty(name)
@@ -33,9 +33,9 @@ android {
defaultConfig {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 119
versionName = "1.0.66"
targetSdk = 36
versionCode = 129
versionName = "1.0.76"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+4
View File
@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
@@ -20,6 +20,7 @@ class AppPreferencesStore(
private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue
@@ -38,4 +42,10 @@ class AppPreferencesStore(
preferences[routeProgressNotificationEnabledKey] = enabled
}
}
suspend fun setRouteLocationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeLocationEnabledKey] = enabled
}
}
}
@@ -22,17 +22,6 @@ class DeviceInfoProvider(
androidVersion = Build.VERSION.RELEASE,
sdkInt = Build.VERSION.SDK_INT,
appVersion = BuildConfig.VERSION_NAME,
serialNumber = readSerialNumber(),
serialNumber = null,
)
private fun readSerialNumber(): String? =
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Build.getSerial()
} else {
@Suppress("DEPRECATION")
Build.SERIAL
}
}.getOrNull()
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
}
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.io.File
import java.security.MessageDigest
import java.util.UUID
@@ -12,6 +13,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -19,8 +21,12 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveDocumentDto
import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -33,6 +39,7 @@ import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -56,24 +63,105 @@ class DriverRepository(
suspend fun bootstrap(date: String? = null): BootstrapResponse =
api.bootstrap(authHeader(requireToken()), date)
suspend fun acknowledgeTachographReminder(taskIds: List<String>): TachographReminderDto =
api.acknowledgeTachographReminder(
authHeader(requireToken()),
AcknowledgeTachographReminderBody(taskIds),
).tachographReminder
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveSummary(): DriverLeaveSummaryDto =
api.leaveSummary(authHeader(requireToken())).data
suspend fun leaveForecast(
dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
details: Map<String, String>,
): DriverLeaveForecastDto =
api.leaveForecast(
authHeader(requireToken()),
LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity, details),
).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
api.leaveCalendar(authHeader(requireToken()), from, to).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun createLeaveRequest(
dateFrom: String,
dateTo: String,
type: String,
requestMode: String,
requestedQuantity: Int?,
details: Map<String, String>,
idempotencyKey: String,
note: String?,
): DriverLeaveRequestDto =
api.createLeaveRequest(
authHeader(requireToken()),
CreateLeaveRequestBody(
dateFrom = dateFrom,
dateTo = dateTo,
type = type,
requestMode = requestMode,
requestedQuantity = requestedQuantity,
details = details,
idempotencyKey = idempotencyKey,
note = note,
),
).data
suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun leaveDocuments(id: String): List<DriverLeaveDocumentDto> =
api.leaveDocuments(authHeader(requireToken()), id).data
suspend fun uploadLeaveDocument(id: String, documentType: String, uri: Uri): DriverLeaveDocumentDto {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val fileMetadata = resolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (!cursor.moveToFirst()) {
null
} else {
cursor.getString(0) to cursor.getLong(1)
}
}
if ((fileMetadata?.second ?: 0L) > 20L * 1024L * 1024L) {
error("Dokument jest za duży. Maksymalny rozmiar pliku to 20 MB.")
}
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
?: error("Nie można odczytać wybranego dokumentu.")
val originalName = fileMetadata?.first
?.takeIf { it.isNotBlank() }
?: "dokument"
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val file = MultipartBody.Part.createFormData("file", originalName, body)
return api.uploadLeaveDocument(
authHeader(requireToken()),
id,
file,
documentType.toPlainTextBody(),
).data
}
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody
import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -11,9 +13,14 @@ import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentListResponse
import pl.firmatpp.kierowca.data.model.LeaveDocumentResponse
import pl.firmatpp.kierowca.data.model.LeaveForecastBody
import pl.firmatpp.kierowca.data.model.LeaveForecastResponse
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.LeaveSummaryResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -67,11 +74,28 @@ interface MobileDriverApi {
@Query("date") date: String?,
): BootstrapResponse
@POST("mobile/driver/tachograph-reminder/acknowledge")
suspend fun acknowledgeTachographReminder(
@Header("Authorization") authorization: String,
@Body body: AcknowledgeTachographReminderBody,
): AcknowledgeTachographReminderResponse
@GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String,
): LeaveRequestTypesResponse
@GET("mobile/driver/leave-summary")
suspend fun leaveSummary(
@Header("Authorization") authorization: String,
): LeaveSummaryResponse
@POST("mobile/driver/leave-forecast")
suspend fun leaveForecast(
@Header("Authorization") authorization: String,
@Body body: LeaveForecastBody,
): LeaveForecastResponse
@GET("mobile/driver/leave-requests")
suspend fun leaveRequests(
@Header("Authorization") authorization: String,
@@ -96,6 +120,21 @@ interface MobileDriverApi {
@Path("id") id: String,
): LeaveRequestResponse
@GET("mobile/driver/leave-requests/{id}/documents")
suspend fun leaveDocuments(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): LeaveDocumentListResponse
@Multipart
@POST("mobile/driver/leave-requests/{id}/documents")
suspend fun uploadLeaveDocument(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Part file: MultipartBody.Part,
@Part("documentType") documentType: RequestBody,
): LeaveDocumentResponse
@POST("mobile/driver/leave-requests/{id}/cancel")
suspend fun cancelLeaveRequest(
@Header("Authorization") authorization: String,
@@ -47,6 +47,7 @@ data class BootstrapResponse(
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
@@ -72,6 +73,7 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
val routeLocationMode: String? = null,
val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null,
@@ -79,12 +81,20 @@ data class DriverAppSettingsDto(
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
val tachographRemindersEnabled: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null,
)
data class LeaveRequestsConfigDto(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
val usagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
)
data class DispatchSheetReminderDto(
@@ -98,6 +108,30 @@ data class DispatchSheetReminderDto(
val canUpload: Boolean = false,
)
data class TachographReminderDto(
val enabled: Boolean = false,
val visible: Boolean = false,
val title: String? = null,
val message: String? = null,
val confirmationLabel: String? = null,
val tasks: List<TachographReminderTaskDto>? = null,
)
data class TachographReminderTaskDto(
val id: String,
val type: String,
val label: String,
)
data class AcknowledgeTachographReminderBody(
val taskIds: List<String>,
)
data class AcknowledgeTachographReminderResponse(
val ok: Boolean,
val tachographReminder: TachographReminderDto,
)
data class DispatchSheetPhotoDto(
val id: String,
val driverId: String,
@@ -196,13 +230,111 @@ data class LeaveRequestTypesResponse(
data class LeaveRequestTypeDto(
val value: String,
val code: String? = null,
val ruleCode: String? = null,
val label: String,
val category: String? = null,
val unit: String? = null,
val poolCode: String? = null,
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val legalBasis: String? = null,
)
data class LeaveSummaryResponse(
val data: DriverLeaveSummaryDto,
)
data class DriverLeaveSummaryDto(
val pools: List<DriverLeavePoolDto> = emptyList(),
val dailyNormMinutes: Int = 480,
val balanceConfigured: Boolean = false,
val overdueQuantity: Int = 0,
val visibleUsagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
val availableTypes: List<LeaveRequestTypeDto> = emptyList(),
val reminders: List<DriverLeaveReminderDto> = emptyList(),
)
data class DriverLeavePoolDto(
val id: Int,
val poolCode: String,
val year: Int,
val unit: String,
val calculatedQuantity: Int = 0,
val confirmedQuantity: Int? = null,
val availableQuantity: Int = 0,
val reservedQuantity: Int = 0,
val usedQuantity: Int = 0,
val grantedQuantity: Int = 0,
val overdue: Boolean = false,
val status: String,
)
data class DriverLeaveReminderDto(
val code: String,
val message: String,
val poolId: Int? = null,
)
data class LeaveForecastBody(
val type: String,
val dateFrom: String,
val dateTo: String,
val requestMode: String,
val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
)
data class LeaveForecastResponse(
val data: DriverLeaveForecastDto,
)
data class DriverLeaveForecastDto(
val ruleCode: String,
val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val quantityUnit: String,
val days: List<DriverLeaveForecastDayDto> = emptyList(),
val workingDaysCount: Int = 0,
val requestedQuantity: Int = 0,
val availableQuantity: Int = 0,
val balanceConfigured: Boolean = false,
val sufficientBalance: Boolean = true,
val pools: List<DriverLeavePoolDto> = emptyList(),
)
data class DriverLeaveForecastDayDto(
val date: String,
val quantity: Int,
val source: String? = null,
)
data class CreateLeaveRequestBody(
val dateFrom: String,
val dateTo: String,
val type: String,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
val idempotencyKey: String? = null,
val note: String?,
)
@@ -217,7 +349,13 @@ data class DriverLeaveRequestDto(
val dateFrom: String?,
val dateTo: String?,
val type: String,
val ruleCode: String? = null,
val typeLabel: String? = null,
val requestMode: String = "days",
val requestedQuantity: Int? = null,
val quantityUnit: String? = null,
val rule: DriverLeaveRuleSnapshotDto? = null,
val details: Map<String, String> = emptyMap(),
val note: String? = null,
val status: String,
val version: Long = 1,
@@ -228,6 +366,65 @@ data class DriverLeaveRequestDto(
val decidedAt: String? = null,
val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(),
val allocations: List<DriverLeaveAllocationDto> = emptyList(),
val balanceConfigurationRequired: Boolean = false,
val missingDocumentTypes: List<String> = emptyList(),
val payrollMode: String? = null,
val payrollItemTypes: List<String> = emptyList(),
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresLegalVerification: Boolean = false,
val legalVerificationChecks: List<String> = emptyList(),
)
data class DriverLeaveRuleSnapshotDto(
val code: String? = null,
val label: String? = null,
val unit: String? = null,
val decisionMode: String? = null,
val payrollMode: String? = null,
val poolCode: String? = null,
val rule: DriverLeaveRuleRequirementsDto? = null,
val legalBasis: String? = null,
)
data class DriverLeaveRuleRequirementsDto(
val requestModes: List<String> = listOf("days"),
val requiredFields: List<String> = emptyList(),
val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
)
data class DriverLeaveAllocationDto(
val id: Int,
val date: String?,
val quantity: Int,
val status: String,
val poolId: Int? = null,
val sourceYear: Int? = null,
)
data class LeaveDocumentListResponse(
val data: List<DriverLeaveDocumentDto> = emptyList(),
)
data class LeaveDocumentResponse(
val data: DriverLeaveDocumentDto,
)
data class DriverLeaveDocumentDto(
val id: Int,
val documentType: String,
val originalName: String,
val mimeType: String? = null,
val sizeBytes: Long = 0,
val uploadedAt: String? = null,
)
data class DriverLeaveCalendarEntryDto(
@@ -5,6 +5,7 @@ import com.google.gson.Gson
import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse
@@ -38,6 +39,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedBootstrap(requestedDate, throwable)
}
}
@@ -58,6 +60,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedRoute(routeId, throwable)
}
@@ -14,6 +14,61 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'start'
AND createdAtEpochMillis < :beforeEpochMillis
ORDER BY createdAtEpochMillis DESC
LIMIT 1
""",
)
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis > :afterEpochMillis
AND status != 'CONFIRMED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND status = 'CONFIRMED'
AND createdAtEpochMillis > :afterEpochMillis
)
""",
)
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
@Query(
"""
UPDATE route_actions
SET status = 'CONFIRMED',
lastError = NULL,
updatedAtEpochMillis = :updatedAt
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis < :beforeEpochMillis
AND status != 'CONFIRMED'
""",
)
suspend fun confirmSupersededFinishes(
routeId: String,
beforeEpochMillis: Long,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity>
@@ -8,9 +8,11 @@ import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -31,6 +33,33 @@ class RouteActionWorker(
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure()
if (action.status == RouteActionStatus.Confirmed) return Result.success()
val startPrerequisite = if (action.action == RouteActionType.Finish) {
dao.latestStartBefore(action.routeId, action.createdAtEpochMillis)
} else {
null
}
when (routeActionPrerequisiteState(action, startPrerequisite)) {
RouteActionPrerequisiteState.Waiting -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Pending,
lastError = "Czekam na potwierdzenie rozpoczęcia kursu.",
attemptIncrement = 0,
)
return Result.retry()
}
RouteActionPrerequisiteState.Failed -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedConflict,
lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.",
attemptIncrement = 0,
)
return Result.failure()
}
RouteActionPrerequisiteState.Ready -> Unit
}
val photoClientRequestIds = photoClientRequestIds(action)
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
@@ -72,12 +101,25 @@ class RouteActionWorker(
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
if (action.action == RouteActionType.Start) {
val outbox = RouteActionOutbox(applicationContext)
dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis)
.forEach { outbox.enqueueWorker(it.clientActionId) }
}
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal(
throwable = throwable,
operation = "route_action_worker",
@@ -89,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code,
"api_status_code" to error.statusCode,
"retryable" to error.retryable,
"server_route_status" to serverRoute?.driverLifecycleStatus(),
),
)
val supersededByConfirmedFinish = action.action == RouteActionType.Finish &&
dao.hasConfirmedFinishAfter(action.routeId, action.createdAtEpochMillis)
if (serverRoute != null &&
(
routeActionMatchesServer(action, photoClientRequestIds, serverRoute) ||
routeActionWasSuperseded(action, supersededByConfirmedFinish, serverRoute)
)
) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Confirmed,
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate)
DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id)
return@getOrElse Result.success()
}
val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable
@@ -99,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus(
clientActionId = clientActionId,
status = status,
lastError = error.message,
lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0,
)
@@ -146,3 +210,84 @@ class RouteActionWorker(
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
}
}
internal fun routeActionMatchesServer(
action: RouteActionEntity,
photoClientRequestIds: List<String>,
serverRoute: DriverRouteDto,
): Boolean {
val lifecycleMatches = when (action.action) {
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
else -> false
}
if (!lifecycleMatches) return false
val serverWeight = when (action.action) {
RouteActionType.Start -> serverRoute.loadingWeight
RouteActionType.Finish -> serverRoute.unloadingWeight
else -> null
}
val serverNotes = when (action.action) {
RouteActionType.Start -> serverRoute.loadingNotes
RouteActionType.Finish -> serverRoute.unloadingNotes
else -> null
}
val weightMatches = when {
action.weight == null && serverWeight == null -> true
action.weight == null || serverWeight == null -> false
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
}
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
val serverPhotoRequestIds = serverRoute.photos
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
.toSet()
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
return weightMatches && notesMatch && photosMatch
}
internal fun routeActionWasSuperseded(
action: RouteActionEntity,
hasConfirmedFinishAfter: Boolean,
serverRoute: DriverRouteDto,
): Boolean =
action.action == RouteActionType.Finish &&
hasConfirmedFinishAfter &&
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
val operationalStatus = serverRoute.operationalStatus
?.takeIf(String::isNotBlank)
?.let { ", status operacyjny: $it" }
.orEmpty()
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
}
private fun String?.normalizedRouteActionNotes(): String? =
this?.trim()?.takeIf(String::isNotEmpty)
internal enum class RouteActionPrerequisiteState {
Ready,
Waiting,
Failed,
}
internal fun routeActionPrerequisiteState(
action: RouteActionEntity,
precedingStart: RouteActionEntity?,
): RouteActionPrerequisiteState {
if (action.action != RouteActionType.Finish || precedingStart == null) {
return RouteActionPrerequisiteState.Ready
}
return when (precedingStart.status) {
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
-> RouteActionPrerequisiteState.Failed
else -> RouteActionPrerequisiteState.Waiting
}
}
@@ -229,18 +229,28 @@ class DiagnosticSnapshotProvider(private val context: Context) {
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val links = activeNetwork?.let(manager::getLinkProperties)
val roaming = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))
} else {
"niedostępne"
}
val mtu = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
links?.mtu?.toString() ?: "brak"
} else {
"niedostępne"
}
return listOf(
DiagnosticEntry("Transport", transports.joinToString()),
DiagnosticEntry("Internet zweryfikowany", yesNo(validated), if (validated) DiagnosticSeverity.Good else DiagnosticSeverity.Error),
DiagnosticEntry("NET_CAPABILITY_INTERNET", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET))),
DiagnosticEntry("NET_CAPABILITY_VALIDATED", yesNo(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))),
DiagnosticEntry("Sieć mierzona", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))),
DiagnosticEntry("Roaming", yesNo(!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING))),
DiagnosticEntry("Roaming", roaming),
DiagnosticEntry("Pasmo downstream / upstream", "${capabilities.linkDownstreamBandwidthKbps} / ${capabilities.linkUpstreamBandwidthKbps} kb/s"),
DiagnosticEntry("Interfejs", links?.interfaceName ?: "brak"),
DiagnosticEntry("Adresy IP", links?.linkAddresses?.joinToString { it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("DNS", links?.dnsServers?.joinToString { it.hostAddress ?: it.toString() }?.ifBlank { "brak" } ?: "brak"),
DiagnosticEntry("MTU", links?.mtu?.toString() ?: "brak"),
DiagnosticEntry("MTU", mtu),
DiagnosticEntry("Proxy", links?.httpProxy?.toString() ?: "brak"),
)
}
@@ -57,7 +57,11 @@ class LeaveRequestDecisionNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
try {
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
} catch (_: SecurityException) {
return Result.success()
}
return Result.success()
}
@@ -73,7 +73,11 @@ class NewRouteNotificationWorker(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
try {
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
} catch (_: SecurityException) {
return@runCatching Result.success()
}
Result.success()
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.tracking
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -54,6 +55,11 @@ class ActiveRouteTrackingService : Service(), LocationListener {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_STOP && !hasLocationPermission()) {
stopSelf()
return START_NOT_STICKY
}
when (intent?.action) {
ACTION_STOP -> {
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
@@ -69,7 +75,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
routeId = id
arrived = true
runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
}
return START_NOT_STICKY
}
@@ -97,7 +105,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
if (routeChanged) arrived = false
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
if (!arrived) requestLocationUpdates()
return START_NOT_STICKY
}
@@ -113,9 +123,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true
runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) return
} else {
startForegroundCompat(buildNotification())
if (!startForegroundCompat(buildNotification())) return
}
val (battery, charging) = batterySnapshot()
@@ -151,6 +161,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
super.onDestroy()
}
@SuppressLint("MissingPermission")
private fun requestLocationUpdates() {
if (!hasLocationPermission() || routeId.isNullOrBlank() || driverId.isNullOrBlank()) {
return
@@ -204,12 +215,26 @@ class ActiveRouteTrackingService : Service(), LocationListener {
return builder.build()
}
private fun startForegroundCompat(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) {
private fun startForegroundCompat(notification: Notification): Boolean {
if (!hasLocationPermission()) {
stopSelf()
return false
}
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else {
startForeground(NOTIFICATION_ID, notification)
}
true
} catch (_: SecurityException) {
stopSelf()
false
} catch (_: IllegalStateException) {
stopSelf()
false
}
}
private fun ensureNotificationChannel() {
@@ -220,7 +245,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
"Aktywny kurs",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Postęp aktywnego kursu i lokalizacja w tle"
description = "Informacje o aktywnym kursie"
}
manager.createNotificationChannel(channel)
}
@@ -299,6 +324,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val ACTIVE_ROUTE_ID = "active_route_id"
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
if (!hasLocationPermission(context)) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_ROUTE_ID, route.id)
@@ -309,7 +336,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) }
}
ContextCompat.startForegroundService(context, intent)
runCatching { ContextCompat.startForegroundService(context, intent) }
}
fun stop(context: Context, routeId: String? = null) {
@@ -325,11 +352,20 @@ class ActiveRouteTrackingService : Service(), LocationListener {
}
fun markArrived(context: Context, routeId: String) {
if (!hasLocationPermission(context)) return
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.getString(ACTIVE_ROUTE_ID, null)
if (activeRouteId != routeId) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_MARK_ARRIVED
putExtra(EXTRA_ROUTE_ID, routeId)
}
ContextCompat.startForegroundService(context, intent)
}
runCatching { ContextCompat.startForegroundService(context, intent) }
}
private fun hasLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
File diff suppressed because it is too large Load Diff
@@ -2,24 +2,36 @@ package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.LeaveRequestTypeDto
data class LeaveRequestsConfig(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
)
data class AnnualLeaveTotals(
val availableMinutes: Int,
val reservedMinutes: Int,
val usedMinutes: Int,
val grantedMinutes: Int,
val overdueMinutes: Int,
)
object DriverLeaveRequestUiRules {
fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean =
config?.enabled == true && config.types.isNotEmpty()
fun statusLabel(status: String): String =
when (status) {
"pending" -> "Oczekuje"
"approved" -> "Zatwierdzony"
"pending" -> "Czeka na decyzję"
"approved" -> "Zaakceptowany"
"rejected" -> "Odrzucony"
"cancel_requested" -> "Anulowanie do decyzji"
"cancel_requested" -> "Czeka na anulowanie"
"cancelled" -> "Anulowany"
"revoked" -> "Cofnięto decyzję"
"revoked" -> "Akceptacja cofnięta"
else -> "Nieznany status"
}
@@ -33,19 +45,245 @@ object DriverLeaveRequestUiRules {
"cancellation_requested" -> "Kierowca poprosił o anulowanie"
"cancellation_approved" -> "Anulowanie zatwierdzone"
"cancellation_rejected" -> "Anulowanie odrzucone"
"period_amended" -> "Zmieniono termin wniosku"
"interrupted" -> "Nieobecność została przerwana"
"annual_leave_interrupted" -> "Przywrócono urlop wypoczynkowy"
else -> "Aktualizacja wniosku"
}
fun typeLabel(type: String): String =
when (type) {
"URLOP" -> "Urlop"
"CHOROBOWE" -> "Chorobowe"
"SZKOLENIE" -> "Szkolenie"
"URLOP", "ANNUAL" -> "Urlop wypoczynkowy"
"ANNUAL_ON_DEMAND" -> "Urlop na żądanie"
"ANNUAL_DISABILITY" -> "Dodatkowy urlop"
"CHOROBOWE", "SICKNESS" -> "Niezdolność do pracy"
"SZKOLENIE", "TRAINING" -> "Urlop szkoleniowy"
"WOLNE" -> "Dzień wolny"
"INNE" -> "Inne"
"INNE", "OTHER_OPERATIONAL" -> "Inna nieobecność"
"FORCE_MAJEURE" -> "Siła wyższa"
"CHILD_CARE_14" -> "Opieka nad dzieckiem do 14 lat"
"CARE_LEAVE" -> "Urlop opiekuńczy"
"MATERNITY" -> "Urlop macierzyński"
"MATERNITY_EQUIVALENT" -> "Urlop na warunkach macierzyńskiego"
"MATERNITY_SUPPLEMENTARY" -> "Uzupełniający urlop macierzyński"
"PARENTAL" -> "Urlop rodzicielski"
"PATERNITY" -> "Urlop ojcowski"
"CHILDCARE_UNPAID" -> "Urlop wychowawczy"
"OCCASIONAL" -> "Zwolnienie okolicznościowe"
"REHABILITATION" -> "Turnus rehabilitacyjny"
"UNPAID" -> "Urlop bezpłatny"
"PREGNANCY_EXAMS" -> "Badania w ciąży"
"OCCUPATIONAL_EXAMS" -> "Badania pracownicze"
"BLOOD_DONATION" -> "Zwolnienie krwiodawcy"
"AUTHORITY_SUMMONS" -> "Wezwanie urzędowe"
"RESCUE_DUTIES" -> "Obowiązki ratownicze"
"MILITARY_DUTIES" -> "Obowiązki wojskowe"
"ORGAN_DONOR_EXAMS" -> "Badania dawcy"
"CARE_SICKNESS" -> "Opieka nad chorym dzieckiem lub członkiem rodziny"
else -> type
}
fun requestType(request: DriverLeaveRequestDto): String =
request.typeLabel?.takeIf { it.isNotBlank() }
?: request.rule?.label?.takeIf { it.isNotBlank() }
?: typeLabel(request.ruleCode ?: request.type)
fun annualTotals(summary: DriverLeaveSummaryDto?): AnnualLeaveTotals {
val annualPools = summary?.pools.orEmpty().filter {
it.poolCode == "ANNUAL" && it.unit == "minutes" && it.status == "active"
}
return AnnualLeaveTotals(
availableMinutes = annualPools.sumOf { it.availableQuantity },
reservedMinutes = annualPools.sumOf { it.reservedQuantity },
usedMinutes = annualPools.sumOf { it.usedQuantity },
grantedMinutes = annualPools.sumOf { it.grantedQuantity },
overdueMinutes = annualPools.filter { it.year < LocalDate.now().year }.sumOf { it.availableQuantity },
)
}
fun annualPools(summary: DriverLeaveSummaryDto?): List<DriverLeavePoolDto> =
summary?.pools.orEmpty()
.filter { it.poolCode == "ANNUAL" && it.status == "active" }
.sortedByDescending { it.year }
fun isUsageVisible(summary: DriverLeaveSummaryDto?, poolCode: String): Boolean =
summary?.visibleUsagePoolCodes?.contains(poolCode) ?: true
fun otherEntitlementPools(summary: DriverLeaveSummaryDto?): List<DriverLeavePoolDto> =
summary?.pools.orEmpty()
.filter { it.poolCode != "ANNUAL" && it.status == "active" }
.sortedWith(compareBy<DriverLeavePoolDto>({ poolLabel(it.poolCode) }, { -it.year }))
fun poolLabel(poolCode: String): String =
when (poolCode) {
"ANNUAL" -> "Urlop wypoczynkowy"
"ANNUAL_DISABILITY" -> "Dodatkowy urlop wypoczynkowy"
"FORCE_MAJEURE" -> "Siła wyższa"
"CHILD_CARE_14" -> "Opieka nad dzieckiem do 14 lat"
"CARE_LEAVE" -> "Urlop opiekuńczy"
else -> typeLabel(poolCode)
}
fun requestQuantityUnit(request: DriverLeaveRequestDto): String {
request.quantityUnit?.takeIf { it == "days" || it == "minutes" }?.let { return it }
if (request.requestMode == "hours") return "minutes"
if (request.rule?.unit == "calendar_days") return "days"
val poolCode = request.rule?.poolCode
return if (
request.requestMode == "days" &&
poolCode != null &&
poolCode !in setOf("ANNUAL", "ANNUAL_DISABILITY")
) {
"days"
} else {
"minutes"
}
}
fun allocationStatusLabel(status: String): String =
when (status) {
"reserved" -> "Uwzględniono w przyszłej nieobecności"
"used" -> "Wykorzystano"
"released" -> "Przywrócono do wykorzystania"
else -> "Rozliczono"
}
fun categoryLabel(category: String?): String =
when (category) {
"annual" -> "Urlop wypoczynkowy"
"statutory_short" -> "Krótkie uprawnienia ustawowe"
"family" -> "Rodzina i opieka"
"parental" -> "Rodzicielstwo"
"health" -> "Zdrowie i rehabilitacja"
"training" -> "Nauka i szkolenia"
"statutory_exemption" -> "Pozostałe zwolnienia ustawowe"
"unpaid" -> "Nieobecności bezpłatne"
"operational" -> "Pozostałe nieobecności"
else -> "Inne rodzaje nieobecności"
}
fun formatQuantity(quantity: Int, unit: String, dailyNormMinutes: Int = 480): String {
if (unit == "days") return dayCountLabel(quantity)
val safeNorm = dailyNormMinutes.coerceAtLeast(1)
val fullDays = quantity / safeNorm
val remainingMinutes = quantity % safeNorm
val hours = quantity / 60
val minutes = quantity % 60
val dayPart = when {
fullDays == 0 && remainingMinutes == 0 -> "0 dni"
remainingMinutes == 0 -> dayCountLabel(fullDays)
fullDays == 0 -> "mniej niż 1 dzień"
else -> "${dayCountLabel(fullDays)} + ${formatHours(remainingMinutes)}"
}
return "$dayPart · ${hours} godz.${if (minutes > 0) " ${minutes} min" else ""}"
}
fun formatHours(minutes: Int): String {
val hours = minutes / 60
val remainder = minutes % 60
return when {
hours == 0 -> "$remainder min"
remainder == 0 -> "$hours godz."
else -> "$hours godz. $remainder min"
}
}
fun dayCountLabel(days: Int): String {
val suffix = when {
days == 1 -> "dzień"
days % 10 in 2..4 && days % 100 !in 12..14 -> "dni"
else -> "dni"
}
return "$days $suffix"
}
fun modeLabel(mode: String): String = if (mode == "hours") "Kilka godzin" else "Całe dni"
fun decisionExplanation(type: LeaveRequestTypeDto): String =
when (type.decisionMode) {
"mandatory_registration" -> "Po spełnieniu wymaganych warunków wniosek jest rejestrowany zgodnie z przepisami."
"external_event" -> "Nieobecność wymaga potwierdzenia odpowiednim dokumentem."
else -> "Wniosek zostanie przekazany do osoby, która podejmie decyzję."
}
fun annualEntitlementImpact(impact: String?): String? =
when (impact) {
"proportional_after_one_month" ->
"Nie pomniejszy puli od razu, ale okres trwający co najmniej miesiąc może zmniejszyć roczny wymiar urlopu po powrocie do pracy."
"proportional_on_return_if_started_before_year" ->
"Po powrocie z urlopu rozpoczętego w poprzednim roku wymiar urlopu wypoczynkowego zostanie naliczony proporcjonalnie."
else -> null
}
fun fieldLabel(field: String): String =
when (field) {
"childBirthDate" -> "Data urodzenia dziecka"
"settlementMode" -> "Sposób wykorzystania w tym roku"
"careRecipientName" -> "Imię i nazwisko osoby wymagającej opieki"
"careRecipientRelationship" -> "Kim jest dla Ciebie ta osoba"
"careReason" -> "Dlaczego potrzebna jest opieka"
"careRecipientAddress" -> "Adres osoby wymagającej opieki"
"childrenBornCount" -> "Liczba dzieci urodzonych przy jednym porodzie"
"entitlementEventKey" -> "Opis zdarzenia"
"partNumber" -> "Która to część urlopu"
"childIdentityKey" -> "Imię dziecka"
"occasionType" -> "Rodzaj zdarzenia rodzinnego"
"examType" -> "Rodzaj egzaminu lub nauki"
"educationProgramKey" -> "Nazwa szkoły lub programu"
"classification" -> "Czego dotyczy nieobecność"
"urgentFamilyReason" -> "Jaka pilna sprawa rodzinna wymaga Twojej obecności?"
"childrenPlacedCount" -> "Liczba dzieci przyjętych na wychowanie"
"placementType" -> "Sposób przyjęcia dziecka na wychowanie"
"hospitalizationGroup" -> "Która sytuacja hospitalizacji dotyczy dziecka?"
"eligibleHospitalDays" -> "Liczba dni hospitalizacji potwierdzona przez szpital"
"maternityLeaveEndsAt" -> "Data zakończenia urlopu macierzyńskiego"
"parentalEntitlementVariant" -> "Wymiar urlopu rodzicielskiego"
"childcareEntitlementVariant" -> "Rodzaj urlopu wychowawczego"
"careRecipientType" -> "Kogo dotyczy opieka?"
"careEntitlementEventKey" -> "Opis okresu opieki"
else -> "Dodatkowa informacja"
}
fun fieldHint(field: String): String? =
when (field) {
"childBirthDate" -> "Wpisz datę w formacie RRRR-MM-DD, np. 2020-05-12"
"entitlementEventKey" -> "Np. poród z 15.06.2026"
"partNumber" -> "Np. 1"
"childIdentityKey" -> "Wystarczy imię i rok urodzenia"
"educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu"
"classification" -> "Krótko opisz powód, aby kadry mogły go prawidłowo zakwalifikować"
"urgentFamilyReason" -> "Krótko opisz chorobę lub wypadek oraz dlaczego Twoja natychmiastowa obecność jest konieczna."
"eligibleHospitalDays" -> "Wpisz liczbę z zaświadczenia, np. 12"
"careEntitlementEventKey" -> "Wpisz prosty opis pozwalający kadrom połączyć części tego samego uprawnienia."
else -> null
}
fun documentLabel(type: String): String =
when (type) {
"disability_certificate" -> "Orzeczenie o niepełnosprawności"
"parent_declaration" -> "Oświadczenie rodzica"
"birth_document", "birth_or_adoption_document" -> "Dokument potwierdzający urodzenie lub przysposobienie"
"adoption_or_care_document" -> "Dokument przysposobienia lub przyjęcia na wychowanie"
"hospitalization_certificate" -> "Zaświadczenie o hospitalizacji"
"parental_leave_declaration", "childcare_leave_declaration" -> "Oświadczenie dotyczące urlopu rodzicielskiego"
"event_document" -> "Dokument potwierdzający zdarzenie"
"training_confirmation" -> "Potwierdzenie nauki lub egzaminu"
"rehabilitation_referral" -> "Skierowanie na turnus rehabilitacyjny"
"medical_referral", "medical_referral_or_confirmation" -> "Skierowanie lub potwierdzenie badania"
"blood_donation_certificate" -> "Zaświadczenie o oddaniu krwi"
"authority_summons" -> "Wezwanie urzędowe"
"rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej"
"military_document" -> "Dokument wojskowy"
"external_sickness_confirmation" -> "Potwierdzenie niezdolności do pracy"
"other_parent_declaration" -> "Oświadczenie drugiego rodzica"
"organ_donor_confirmation" -> "Potwierdzenie badań lub zabiegu dawcy"
"external_care_confirmation" -> "Potwierdzenie opieki lub e-ZLA"
else -> "Wymagany dokument"
}
fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean {
if (status == "pending") return true
if (status != "approved") return false
@@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent,
)
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionLifecycleProjectionStatuses = setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
RouteActionStatus.Confirmed,
)
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
.filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses }
var projectedRoute = route
var loadingWeightPending = false
var unloadingWeightPending = false
@@ -39,25 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
when (action.action) {
RouteActionType.Start -> {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight,
loadingNotes = action.notes,
trackingStatus = "active",
photos = projectedRoute.safePhotos(),
)
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
trackingStatus = "active",
)
}
loadingWeightPending = action.status != RouteActionStatus.Confirmed
}
RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight,
unloadingNotes = action.notes,
trackingStatus = "finished",
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
)
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
trackingStatus = "finished",
completedAt = action.occurredAt,
)
}
unloadingWeightPending = action.status != RouteActionStatus.Confirmed
}
}
@@ -14,6 +14,8 @@ import kotlin.math.abs
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.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -40,6 +42,42 @@ data class AppUpdateBannerUi(
val downloaded: Boolean,
)
object RouteLocationMode {
const val Disabled = "disabled"
const val Optional = "optional"
const val Required = "required"
}
fun normalizeRouteLocationMode(value: String?): String = when (value) {
RouteLocationMode.Disabled,
RouteLocationMode.Required,
-> value
else -> RouteLocationMode.Optional
}
fun shouldTrackRouteLocation(
mode: String,
optionalLocationEnabled: Boolean,
hasLocationPermission: Boolean,
): Boolean = hasLocationPermission && when (normalizeRouteLocationMode(mode)) {
RouteLocationMode.Disabled -> false
RouteLocationMode.Required -> true
else -> optionalLocationEnabled
}
fun routeStartRequiresLocationPermission(
mode: String,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && !hasLocationPermission
fun shouldWarnAboutRequiredRouteLocation(
mode: String,
hasActiveRoute: Boolean,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required &&
hasActiveRoute &&
!hasLocationPermission
fun appUpdateBannerUi(
currentVersionCode: Int,
latestVersionCode: Int?,
@@ -635,6 +673,16 @@ fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLo
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun shouldShowTachographReminderCard(reminder: TachographReminderDto?): Boolean =
reminder?.enabled == true &&
reminder.visible &&
!reminder.title.isNullOrBlank() &&
!reminder.message.isNullOrBlank() &&
!reminder.tasks.isNullOrEmpty()
fun defaultTachographConfirmationSelection(tasks: List<TachographReminderTaskDto>): Set<String> =
if (tasks.size == 1) setOf(tasks.single().id) else emptySet()
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when {
hasQueuedUpload -> "Wysyłam zdjęcie..."
@@ -1,11 +1,16 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.app.Application
import android.content.pm.PackageManager
import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -15,11 +20,13 @@ import kotlinx.coroutines.sync.withLock
import java.time.LocalDate
import java.time.Instant
import java.io.IOException
import java.util.UUID
import pl.firmatpp.kierowca.data.AppPreferencesStore
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader
@@ -27,9 +34,13 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.AppUpdateDto
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveDocumentDto
import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
@@ -81,6 +92,8 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
val routeLocationMode: String = RouteLocationMode.Optional,
val routeLocationEnabled: Boolean = false,
val loadingPhotoRequirement: String = RouteStageRequirement.Required,
val loadingWeightRequirement: String = RouteStageRequirement.Required,
val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
@@ -97,15 +110,26 @@ data class DriverUiState(
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val tachographReminderSubmitting: Boolean = false,
val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveSummary: DriverLeaveSummaryDto? = 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 leaveRequestMode: String = "days",
val leaveRequestHoursText: String = "",
val leaveRequestDetails: Map<String, String> = emptyMap(),
val leaveRequestIdempotencyKey: String = "",
val leaveRequestNote: String = "",
val leaveForecast: DriverLeaveForecastDto? = null,
val leaveForecastLoading: Boolean = false,
val leaveDocuments: List<DriverLeaveDocumentDto> = emptyList(),
val leaveDocumentUploading: Boolean = false,
val leaveCalendarEntries: List<DriverLeaveCalendarEntryDto> = emptyList(),
val leaveCalendarLoading: Boolean = false,
val leaveCalendarLoadedUntil: String? = null,
@@ -194,6 +218,31 @@ internal fun DriverUiState.withLoadedLeaveRequests(
)
}
internal fun shouldStartRoutesLoad(
screen: DriverScreen,
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean,
startupLoadInProgress: Boolean = false,
): Boolean =
(!startupLoadInProgress || navigateToRoutes || startupOfflineOnly) &&
(screen != DriverScreen.Initializing || navigateToRoutes || startupOfflineOnly)
internal fun screenAfterBootstrap(
currentScreen: DriverScreen,
navigateToRoutes: Boolean,
leaveRequestsEnabled: Boolean,
): DriverScreen = when {
currentScreen == DriverScreen.Initializing -> DriverScreen.Routes
!leaveRequestsEnabled && currentScreen in setOf(
DriverScreen.LeaveRequests,
DriverScreen.LeaveRequestDetail,
DriverScreen.LeaveCalendar,
DriverScreen.AddLeaveRequest,
) -> DriverScreen.Routes
navigateToRoutes -> DriverScreen.Routes
else -> currentScreen
}
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application)
@@ -228,6 +277,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var routesRequestGeneration: Long = 0
private var routeDetailRequestGeneration: Long = 0
private var routesLoadJob: Job? = null
private var startupLoadInProgress: Boolean = false
val state: StateFlow<DriverUiState> = _state
init {
@@ -241,6 +291,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeProgressNotificationEnabled = enabled) }
}
}
viewModelScope.launch {
appPreferencesStore.routeLocationEnabled.collect { enabled ->
_state.update { it.copy(routeLocationEnabled = enabled) }
reconcileTrackingServiceFromState()
}
}
viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) }
@@ -459,6 +515,39 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun acknowledgeTachographReminder(taskIds: List<String>) {
val snapshot = _state.value
if (taskIds.isEmpty() || snapshot.tachographReminderSubmitting) return
if (!snapshot.isOnline) {
_state.update { it.copy(error = "Potwierdzenie odczytu wymaga połączenia z internetem.") }
return
}
viewModelScope.launch {
_state.update {
it.copy(
tachographReminderSubmitting = true,
error = null,
feedback = null,
)
}
runCatching { repository.acknowledgeTachographReminder(taskIds) }
.onSuccess { reminder ->
_state.update {
it.copy(
tachographReminder = reminder,
feedback = "Zapisano potwierdzenie odczytu.",
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(tachographReminderSubmitting = false) }
}
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
@@ -469,24 +558,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean = false,
) {
val requestSnapshot = _state.value
if (!shouldStartRoutesLoad(
screen = requestSnapshot.screen,
navigateToRoutes = navigateToRoutes,
startupOfflineOnly = startupOfflineOnly,
startupLoadInProgress = startupLoadInProgress,
)
) {
return
}
val isStartup = requestSnapshot.screen == DriverScreen.Initializing
if (isStartup) startupLoadInProgress = true
val generation = ++routesRequestGeneration
routesLoadJob?.cancel()
routesLoadJob = viewModelScope.launch {
val isStartup = _state.value.screen == DriverScreen.Initializing
val loadJob = viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching {
val cached = when {
startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
isStartup -> {
startupOfflineFallbackLoader.load(
onlineLoad = { syncRepository.bootstrap(date) },
offlineLoad = { syncRepository.bootstrapFromCache(date) },
)
}
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
else -> syncRepository.bootstrap(date)
}
val response = cached.value
@@ -498,6 +598,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver,
routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
tachographReminder = response.tachographReminder,
appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate,
@@ -507,6 +608,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
routeLocationMode = normalizeRouteLocationMode(settings?.routeLocationMode),
loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement),
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
@@ -514,10 +616,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
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,
screen = screenAfterBootstrap(
currentScreen = it.screen,
navigateToRoutes = navigateToRoutes,
leaveRequestsEnabled = settings?.leaveRequests?.enabled == true,
),
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
@@ -531,10 +634,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId)
}
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
if (_state.value.isOnline && !cached.stale && settings?.selectedDate == settings?.today) {
val trackingSnapshot = _state.value
if (
!shouldTrackRouteLocation(
trackingSnapshot.routeLocationMode,
trackingSnapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.stop(getApplication())
} else if (trackingSnapshot.isOnline && !cached.stale && settings?.selectedDate == settings?.today) {
reconcileTrackingService(response.routes.today, response.session.driver)
}
}.onFailure { throwable ->
throwable.rethrowIfCancellation()
if (generation != routesRequestGeneration) return@onFailure
reportHandledException("load_routes", throwable, mapOf("date" to date))
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
@@ -552,6 +665,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy(loading = false, refreshing = false)
}
}
routesLoadJob = loadJob
if (isStartup) {
loadJob.invokeOnCompletion {
if (generation == routesRequestGeneration) {
startupLoadInProgress = false
}
}
}
}
fun openRoute(routeId: String) = openRouteWithTarget(routeId, DriverScreen.Detail)
@@ -640,6 +761,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openProfile() {
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
if (
DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig) &&
_state.value.isOnline
) {
viewModelScope.launch { loadLeaveOverview(showLoading = false, navigateToList = false) }
}
}
fun openDiagnostics() {
@@ -700,6 +827,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"),
DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"),
DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"),
DiagnosticEntry("Lokalizacja kursu", runtime.routeLocationMode),
DiagnosticEntry("Wybór kierowcy", if (runtime.routeLocationEnabled) "włączony" else "wyłączony"),
DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement),
DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement),
DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement),
@@ -796,6 +925,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun setRouteLocationEnabled(enabled: Boolean) {
if (_state.value.routeLocationMode != RouteLocationMode.Optional) return
_state.update { it.copy(routeLocationEnabled = enabled, error = null) }
reconcileTrackingServiceFromState()
viewModelScope.launch {
runCatching { appPreferencesStore.setRouteLocationEnabled(enabled) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun onLocationPermissionGranted() {
val snapshot = _state.value
val driver = snapshot.driver ?: return
if (!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())) {
return
}
val activeRoute = snapshot.displaySelectedRoute
?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" }
?: snapshot.displayRoutes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
?: return
ActiveRouteTrackingService.start(
context = getApplication(),
route = activeRoute,
driver = driver,
progressEnabled = snapshot.routeProgressNotificationEnabled,
)
}
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
@@ -824,13 +983,34 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private suspend fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean): Boolean {
return loadLeaveOverview(showLoading, navigateToList)
}
private suspend fun loadLeaveOverview(showLoading: Boolean, navigateToList: Boolean): Boolean {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
val result = runCatching { repository.leaveRequests() }
result.onSuccess { requests ->
val (requestsResult, summaryResult) = coroutineScope {
val requests = async { runCatching { repository.leaveRequests() } }
val summary = async { runCatching { repository.leaveSummary() } }
requests.await() to summary.await()
}
requestsResult.onSuccess { requests ->
_state.update { it.withLoadedLeaveRequests(requests, navigateToList) }
}.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
summaryResult.onSuccess { summary ->
_state.update {
it.copy(
leaveSummary = summary,
leaveForecast = if (it.screen == DriverScreen.AddLeaveRequest) null else it.leaveForecast,
)
}
}.onFailure { throwable ->
if (requestsResult.isSuccess) {
val message = ApiErrorMapper.map(throwable).message
_state.update { it.copy(error = "Nie udało się odświeżyć salda urlopu. $message") }
}
}
_state.update { it.copy(loading = false, refreshing = false) }
return result.isSuccess
return requestsResult.isSuccess
}
fun openLeaveRequest(id: String) {
@@ -848,9 +1028,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
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) }
val (requestResult, documentsResult) = coroutineScope {
val leaveRequest = async { runCatching { repository.leaveRequest(id) } }
val documents = async { runCatching { repository.leaveDocuments(id) } }
leaveRequest.await() to documents.await()
}
requestResult
.onSuccess { leaveRequest ->
_state.update {
it.copy(
screen = DriverScreen.LeaveRequestDetail,
selectedLeaveRequest = leaveRequest,
leaveDocuments = documentsResult.getOrDefault(emptyList()),
error = documentsResult.exceptionOrNull()?.let { error ->
"Nie udało się pobrać listy dokumentów. ${ApiErrorMapper.map(error).message}"
},
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false) }
@@ -864,14 +1058,44 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(error = "Złożenie wniosku urlopowego wymaga połączenia z internetem.") }
return
}
if (_state.value.leaveSummary?.availableTypes.isNullOrEmpty()) {
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { repository.leaveSummary() }
.onSuccess { summary ->
_state.update { it.copy(leaveSummary = summary, loading = false) }
beginLeaveRequestDraft(config)
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable).copy(loading = false) }
}
}
return
}
beginLeaveRequestDraft(config)
}
private fun beginLeaveRequestDraft(config: LeaveRequestsConfig?) {
val today = LocalDate.now().toString()
val defaultType = _state.value.leaveSummary?.availableTypes?.firstOrNull()
val type = defaultType?.value ?: config?.types?.firstOrNull() ?: "URLOP"
val mode = defaultType?.requestModes?.firstOrNull() ?: "days"
_state.update {
it.copy(
screen = DriverScreen.LeaveCalendar,
screen = DriverScreen.AddLeaveRequest,
leaveRequestDateFrom = today,
leaveRequestDateTo = today,
leaveRequestType = config?.types?.firstOrNull() ?: "URLOP",
leaveRequestType = type,
leaveRequestMode = mode,
leaveRequestHoursText = "",
leaveRequestDetails = if ("settlementMode" in defaultType?.requiredFields.orEmpty()) {
mapOf("settlementMode" to mode)
} else {
emptyMap()
},
leaveRequestIdempotencyKey = UUID.randomUUID().toString(),
leaveRequestNote = "",
leaveForecast = null,
leaveCalendarEntries = emptyList(),
leaveCalendarLoadedUntil = null,
leaveCalendarHasSelection = false,
@@ -880,22 +1104,64 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
feedback = null,
)
}
loadMoreLeaveCalendarMonths(reset = true)
}
fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) {
fun updateLeaveRequestType(type: String) {
val metadata = _state.value.leaveSummary?.availableTypes?.firstOrNull { it.value == type }
_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,
leaveRequestType = type,
leaveRequestMode = metadata?.requestModes?.firstOrNull() ?: "days",
leaveRequestHoursText = "",
leaveRequestDetails = if ("settlementMode" in metadata?.requiredFields.orEmpty()) {
mapOf("settlementMode" to (metadata?.requestModes?.firstOrNull() ?: "days"))
} else {
emptyMap()
},
leaveForecast = null,
error = null,
)
}
}
fun updateLeaveRequestMode(mode: String) {
val metadata = selectedLeaveRequestType() ?: return
if (mode !in metadata.requestModes) return
_state.update {
it.copy(
leaveRequestMode = mode,
leaveRequestDateTo = if (mode == "hours") it.leaveRequestDateFrom else it.leaveRequestDateTo,
leaveRequestHoursText = if (mode == "hours") it.leaveRequestHoursText else "",
leaveRequestDetails = if ("settlementMode" in metadata.requiredFields) {
it.leaveRequestDetails + ("settlementMode" to mode)
} else {
it.leaveRequestDetails
},
leaveForecast = null,
error = null,
)
}
}
fun updateLeaveRequestHours(value: String) {
val normalized = value.filter { it.isDigit() || it == ',' || it == '.' }.take(5)
_state.update { it.copy(leaveRequestHoursText = normalized, leaveForecast = null, error = null) }
}
fun updateLeaveRequestDetail(field: String, value: String) {
_state.update {
it.copy(
leaveRequestDetails = it.leaveRequestDetails + (field to value),
leaveForecast = null,
error = null,
)
}
}
fun updateLeaveRequestNote(value: String) {
_state.update { it.copy(leaveRequestNote = value, error = null) }
}
fun selectLeaveCalendarDate(date: String) {
val selected = runCatching { LocalDate.parse(date) }.getOrNull() ?: return
if (selected.isBefore(LocalDate.now())) return
@@ -931,7 +1197,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
return
}
_state.update { it.copy(screen = DriverScreen.AddLeaveRequest, error = null, feedback = null) }
_state.update {
it.copy(
screen = DriverScreen.AddLeaveRequest,
leaveRequestDateTo = if (it.leaveRequestMode == "hours") it.leaveRequestDateFrom else it.leaveRequestDateTo,
leaveForecast = null,
error = null,
feedback = null,
)
}
}
fun reopenLeaveCalendarFromDraft() {
@@ -949,6 +1223,53 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun refreshLeaveForecast() {
val snapshot = _state.value
if (!snapshot.isOnline || snapshot.leaveForecastLoading) return
if (!snapshot.leaveCalendarHasSelection) {
_state.update { it.copy(error = "Najpierw wybierz termin nieobecności.") }
return
}
val type = selectedLeaveRequestType()
if (type == null) {
_state.update { it.copy(error = "Wybierz rodzaj nieobecności.") }
return
}
val missingField = type.requiredFields
.filterNot { it == "settlementMode" }
.firstOrNull { snapshot.leaveRequestDetails[it].isNullOrBlank() }
if (missingField != null) {
_state.update {
it.copy(error = "Uzupełnij pole „${DriverLeaveRequestUiRules.fieldLabel(missingField)}”.")
}
return
}
val requestedMinutes = requestedLeaveMinutes(snapshot)
if (snapshot.leaveRequestMode == "hours" && requestedMinutes == null) {
_state.update { it.copy(error = "Wpisz liczbę godzin, o które chcesz wnioskować.") }
return
}
viewModelScope.launch {
_state.update { it.copy(leaveForecastLoading = true, leaveForecast = null, error = null) }
runCatching {
repository.leaveForecast(
dateFrom = snapshot.leaveRequestDateFrom,
dateTo = snapshot.leaveRequestDateTo,
type = snapshot.leaveRequestType,
requestMode = snapshot.leaveRequestMode,
requestedQuantity = requestedMinutes,
details = snapshot.leaveRequestDetails,
)
}.onSuccess { forecast ->
_state.update { it.copy(leaveForecast = forecast, error = null) }
}.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(leaveForecastLoading = false) }
}
}
fun loadMoreLeaveCalendarMonths(reset: Boolean = false) {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig) || snapshot.leaveCalendarLoading) return
@@ -1003,6 +1324,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(error = "Wysłanie wniosku wymaga połączenia z internetem.") }
return
}
if (snapshot.leaveForecast == null) {
_state.update { it.copy(error = "Najpierw wybierz „Sprawdź wniosek”, aby zobaczyć jego podsumowanie.") }
return
}
if (!snapshot.leaveForecast.sufficientBalance) {
_state.update { it.copy(error = "Nie możesz wysłać tego wniosku, ponieważ dostępne saldo jest za małe.") }
return
}
if (!leaveMutationMutex.tryLock()) return
if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) {
_state.update { it.copy(error = "Data od nie może być z przeszłości.") }
@@ -1018,16 +1347,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
dateFrom = snapshot.leaveRequestDateFrom,
dateTo = snapshot.leaveRequestDateTo,
type = snapshot.leaveRequestType,
requestMode = snapshot.leaveRequestMode,
requestedQuantity = requestedLeaveMinutes(snapshot),
details = snapshot.leaveRequestDetails,
idempotencyKey = snapshot.leaveRequestIdempotencyKey.ifBlank { UUID.randomUUID().toString() },
note = snapshot.leaveRequestNote.takeIf { it.isNotBlank() },
)
}.onSuccess { created ->
val requests = runCatching { repository.leaveRequests() }.getOrElse { listOf(created) }
val (requests, summary) = coroutineScope {
val refreshedRequests = async { runCatching { repository.leaveRequests() }.getOrElse { listOf(created) } }
val refreshedSummary = async { runCatching { repository.leaveSummary() }.getOrNull() }
refreshedRequests.await() to refreshedSummary.await()
}
_state.update {
it.copy(
screen = DriverScreen.LeaveRequestDetail,
selectedLeaveRequest = created,
leaveDocuments = emptyList(),
leaveRequests = requests,
feedback = "Wniosek został wysłany do decyzji.",
leaveSummary = summary ?: it.leaveSummary,
feedback = if (created.balanceConfigurationRequired) {
"Wniosek zapisano. Kadry muszą jeszcze potwierdzić saldo, zanim będzie można go zaakceptować."
} else {
"Gotowe. Wniosek został wysłany i odpowiednia część salda jest już zarezerwowana."
},
error = null,
)
}
@@ -1042,6 +1385,51 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun uploadSelectedLeaveDocument(documentType: String, uri: Uri) {
val request = _state.value.selectedLeaveRequest ?: return
if (!_state.value.isOnline || _state.value.leaveDocumentUploading) {
if (!_state.value.isOnline) {
_state.update { it.copy(error = "Dodanie dokumentu wymaga połączenia z internetem.") }
}
return
}
viewModelScope.launch {
_state.update { it.copy(leaveDocumentUploading = true, error = null, feedback = null) }
runCatching { repository.uploadLeaveDocument(request.id, documentType, uri) }
.onSuccess { document ->
_state.update {
val updatedRequest = it.selectedLeaveRequest?.copy(
missingDocumentTypes = it.selectedLeaveRequest.missingDocumentTypes - documentType,
)
it.copy(
selectedLeaveRequest = updatedRequest,
leaveRequests = it.leaveRequests.map { leaveRequest ->
if (leaveRequest.id == updatedRequest?.id) updatedRequest else leaveRequest
},
leaveDocuments = (it.leaveDocuments.filterNot { existing ->
existing.documentType == document.documentType
} + document).sortedBy { item -> item.documentType },
feedback = "Dokument został bezpiecznie dołączony do wniosku.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(leaveDocumentUploading = false) }
}
}
private fun selectedLeaveRequestType() =
_state.value.leaveSummary?.availableTypes?.firstOrNull { it.value == _state.value.leaveRequestType }
private fun requestedLeaveMinutes(state: DriverUiState): Int? {
if (state.leaveRequestMode != "hours") return null
val hours = state.leaveRequestHoursText.replace(',', '.').toDoubleOrNull() ?: return null
val minutes = (hours * 60).toInt()
return minutes.takeIf { it > 0 }
}
fun cancelSelectedLeaveRequest() {
val request = _state.value.selectedLeaveRequest ?: return
if (!_state.value.isOnline) {
@@ -1056,10 +1444,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
try {
runCatching { repository.cancelLeaveRequest(request.id, request.version) }
.onSuccess { updated ->
val refreshedSummary = runCatching { repository.leaveSummary() }.getOrNull()
_state.update {
it.copy(
selectedLeaveRequest = updated,
leaveRequests = it.leaveRequests.map { existing -> if (existing.id == updated.id) updated else existing },
leaveSummary = refreshedSummary ?: it.leaveSummary,
feedback = if (updated.status == "cancel_requested") "Anulowanie wysłane do decyzji." else "Wniosek został anulowany.",
error = null,
)
@@ -1107,6 +1497,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openStartRoute() {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return
@@ -1211,6 +1605,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun retryConflictedRouteAction() {
val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions)
.distinctBy { it.clientActionId }
.filter { it.status == RouteActionStatus.FailedConflict }
.maxByOrNull { it.createdAtEpochMillis }
?: return
viewModelScope.launch {
runCatching {
routeActionOutbox.retry(action.clientActionId)
}
.onSuccess {
_state.update {
it.copy(
feedback = "Ponawiam wysyłanie zapisanych danych.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun submitStartRoute() {
submitRouteStage(stage = "loading")
}
@@ -1222,6 +1640,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun submitRouteStage(stage: String) {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (stage == "loading" && routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
val canSubmitStage = if (stage == "loading") {
canStartRouteFromDriverApp(route, snapshot.selectedDate)
} else {
@@ -1333,7 +1755,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
if (stage == "loading" && driver != null) {
if (
stage == "loading" &&
driver != null &&
shouldTrackRouteLocation(
snapshot.routeLocationMode,
snapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.start(
context = getApplication(),
route = updatedRoute,
@@ -1517,7 +1947,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.LeaveCalendar -> it.copy(
screen = if (it.leaveRequestIdempotencyKey.isNotBlank()) DriverScreen.AddLeaveRequest else DriverScreen.LeaveRequests,
feedback = null,
)
DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> {
photoUploadsJob?.cancel()
@@ -1597,8 +2030,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val snapshot = _state.value
val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (activeRoute == null) {
if (
activeRoute == null ||
!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
ActiveRouteTrackingService.stop(getApplication())
} else {
ActiveRouteTrackingService.start(
@@ -1611,7 +2048,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
if (route.driverLifecycleStatus() == "W TRAKCIE") {
val snapshot = _state.value
if (
route.driverLifecycleStatus() == "W TRAKCIE" &&
shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
if (driver != null) {
ActiveRouteTrackingService.start(
context = getApplication(),
@@ -1625,6 +2066,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
private fun reconcileTrackingServiceFromState() {
val snapshot = _state.value
val driver = snapshot.driver
if (driver == null) {
ActiveRouteTrackingService.stop(getApplication())
return
}
reconcileTrackingService(snapshot.displayRoutes, driver)
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun observeRouteActions(routeId: String) {
routeActionsJob?.cancel()
routeActionsJob = viewModelScope.launch {
@@ -1658,7 +2113,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private suspend fun checkRemoteSyncState() {
if (!_state.value.isOnline) return
val initialSnapshot = _state.value
if (
!initialSnapshot.isOnline ||
initialSnapshot.screen == DriverScreen.Initializing ||
initialSnapshot.driver == null
) return
syncCheckMutex.withLock {
syncHintMutex.withLock { highestHintVersions.clear() }
val snapshot = _state.value
@@ -1705,6 +2165,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private suspend fun handleSyncHint(hint: DriverSyncHint) {
val initialSnapshot = _state.value
if (initialSnapshot.screen == DriverScreen.Initializing || initialSnapshot.driver == null) return
syncHintMutex.withLock {
val hintKey = listOf(hint.scope, hint.date ?: "-", hint.routeId ?: "-").joinToString(":")
val previousVersion = highestHintVersions[hintKey]
@@ -1741,7 +2203,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (pending == null || hint.version > pending.version) pendingLeaveHint = hint
return
}
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
if (snapshot.screen in setOf(
DriverScreen.Profile,
DriverScreen.LeaveRequests,
DriverScreen.LeaveRequestDetail,
DriverScreen.AddLeaveRequest,
)
) {
if (loadLeaveRequests(showLoading = false, navigateToList = false)) {
syncRepository.saveSyncScope(hint.asScope())
}
@@ -0,0 +1,252 @@
package pl.firmatpp.kierowca.ui
import android.content.Context
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File
import pl.firmatpp.kierowca.ui.theme.TppTheme
@Composable
internal fun MultiplePhotoCameraScreen(
stage: String,
onPhotoCaptured: (Uri) -> Unit,
onClose: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnPhotoCaptured by rememberUpdatedState(onPhotoCaptured)
val cameraController = remember(context) {
LifecycleCameraController(context).apply {
setEnabledUseCases(CameraController.IMAGE_CAPTURE)
imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
}
}
var capturedCount by remember { mutableIntStateOf(0) }
var isCapturing by remember { mutableStateOf(false) }
var cameraUnavailable by remember { mutableStateOf(false) }
var cameraError by remember { mutableStateOf<String?>(null) }
DisposableEffect(cameraController, lifecycleOwner) {
runCatching {
cameraController.bindToLifecycle(lifecycleOwner)
}.onFailure {
cameraUnavailable = true
cameraError = "Nie udało się uruchomić aparatu."
}
onDispose {
cameraController.unbind()
}
}
BackHandler {
if (!isCapturing) onClose()
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { previewContext ->
PreviewView(previewContext).apply {
controller = cameraController
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
},
modifier = Modifier.fillMaxSize(),
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.background(Color.Black.copy(alpha = 0.62f))
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onClose,
enabled = !isCapturing,
) {
Icon(
Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Wróć",
tint = Color.White,
)
}
Column {
Text(
text = if (stage == "loading") "Zdjęcia załadunku" else "Zdjęcia rozładunku",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Wykonano: $capturedCount",
color = Color.White.copy(alpha = 0.78f),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(Color.Black.copy(alpha = 0.68f))
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
cameraError?.let { message ->
Text(
text = message,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF8C1D18), RoundedCornerShape(6.dp))
.padding(10.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Spacer(Modifier.size(112.dp, 52.dp))
Button(
onClick = {
if (isCapturing || cameraUnavailable) return@Button
val photoFile = createMultipleCameraFile(context)
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
isCapturing = true
cameraController.takePicture(
outputOptions,
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
isCapturing = false
cameraError = null
capturedCount += 1
currentOnPhotoCaptured(cameraFileUri(context, photoFile))
}
override fun onError(exception: ImageCaptureException) {
isCapturing = false
photoFile.delete()
cameraError = "Nie udało się zapisać zdjęcia. Spróbuj ponownie."
}
},
)
},
enabled = !isCapturing && !cameraUnavailable,
modifier = Modifier.size(78.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
disabledContainerColor = Color.White.copy(alpha = 0.55f),
),
contentPadding = ButtonDefaults.ContentPadding,
) {
if (isCapturing) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = TppTheme.colors.forest,
strokeWidth = 3.dp,
)
} else {
Icon(
Icons.Outlined.CameraAlt,
contentDescription = "Zrób zdjęcie",
tint = TppTheme.colors.forest,
modifier = Modifier.size(32.dp),
)
}
}
Button(
onClick = onClose,
enabled = !isCapturing,
modifier = Modifier.size(width = 112.dp, height = 52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = TppTheme.colors.forest,
),
shape = RoundedCornerShape(6.dp),
) {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
Text(
text = "Gotowe",
color = Color.White,
modifier = Modifier.padding(start = 6.dp),
)
}
}
}
}
}
private fun createMultipleCameraFile(context: Context): File {
val directory = File(context.cacheDir, "camera").apply { mkdirs() }
return File.createTempFile("ladunek-", ".jpg", directory)
}
private fun cameraFileUri(context: Context, file: File): Uri =
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
@@ -0,0 +1,97 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
private const val PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS = 5_000L
internal suspend fun currentPhotoLocation(context: Context): Location? {
if (!hasAnyLocationPermission(context)) return null
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val hasFineLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val providers = buildList {
if (hasFineLocation) add(LocationManager.GPS_PROVIDER)
add(LocationManager.NETWORK_PROVIDER)
}.filter { provider -> runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false) }
for (provider in providers) {
val location = withTimeoutOrNull(PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS) {
manager.awaitSingleLocation(context, provider)
}
if (location != null) return location
}
return bestLastKnownLocation(manager)
}
@SuppressLint("MissingPermission")
private suspend fun LocationManager.awaitSingleLocation(
context: Context,
provider: String,
): Location? = suspendCancellableCoroutine { continuation ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancellationSignal = CancellationSignal()
continuation.invokeOnCancellation { cancellationSignal.cancel() }
runCatching {
getCurrentLocation(provider, cancellationSignal, context.mainExecutor) { location ->
if (continuation.isActive) continuation.resume(location)
}
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
return@suspendCancellableCoroutine
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
removeUpdates(this)
if (continuation.isActive) continuation.resume(location)
}
}
continuation.invokeOnCancellation { runCatching { removeUpdates(listener) } }
runCatching {
@Suppress("DEPRECATION")
requestSingleUpdate(provider, listener, Looper.getMainLooper())
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
}
private fun hasAnyLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun bestLastKnownLocation(manager: LocationManager): Location? {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
return providers.mapNotNull { provider ->
runCatching {
if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) {
manager.getLastKnownLocation(provider)
} else {
null
}
}.getOrNull()
}.maxByOrNull { it.time }
}
@@ -0,0 +1,94 @@
package pl.firmatpp.kierowca.data
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse
class DriverLeaveApiContractTest {
private val gson = Gson()
@Test
fun parsesNewLeaveSummaryWithoutTechnicalFallbacks() {
val response = gson.fromJson(
"""
{
"data": {
"dailyNormMinutes": 480,
"balanceConfigured": true,
"overdueQuantity": 480,
"visibleUsagePoolCodes": ["ANNUAL", "CHILD_CARE_14"],
"pools": [{
"id": 7,
"poolCode": "ANNUAL",
"year": 2026,
"unit": "minutes",
"availableQuantity": 7680,
"reservedQuantity": 960,
"usedQuantity": 960,
"grantedQuantity": 9600,
"status": "active"
}],
"availableTypes": [{
"value": "URLOP",
"code": "ANNUAL",
"label": "Urlop wypoczynkowy",
"requestModes": ["days", "hours"],
"requiredFields": [],
"requiredDocuments": [],
"fieldOptions": {},
"description": "Urlop rozliczany według grafiku.",
"financialTreatmentLabel": "Płatny według danych przekazanych przez kadry i płace.",
"affectsAnnualLeaveBalance": true
}]
}
}
""".trimIndent(),
LeaveSummaryResponse::class.java,
)
assertEquals(480, response.data.dailyNormMinutes)
assertEquals(listOf("ANNUAL", "CHILD_CARE_14"), response.data.visibleUsagePoolCodes)
assertEquals(7680, response.data.pools.single().availableQuantity)
assertEquals("URLOP", response.data.availableTypes.single().value)
assertEquals("ANNUAL", response.data.availableTypes.single().code)
assertTrue("hours" in response.data.availableTypes.single().requestModes)
assertTrue(response.data.availableTypes.single().affectsAnnualLeaveBalance)
}
@Test
fun parsesSensitiveOwnRequestDetailsAsFormValues() {
val request = gson.fromJson(
"""
{
"id": "12",
"dateFrom": "2026-08-03",
"dateTo": "2026-08-03",
"type": "WOLNE",
"ruleCode": "CHILD_CARE_14",
"requestMode": "days",
"requestedQuantity": 1,
"quantityUnit": "days",
"details": {
"childrenBornCount": 1,
"childBirthDate": "2020-05-12"
},
"financialTreatmentLabel": "Płatne według zasad ustawowych.",
"affectsAnnualLeaveBalance": false,
"status": "pending",
"missingDocumentTypes": ["parent_declaration"]
}
""".trimIndent(),
DriverLeaveRequestDto::class.java,
)
assertEquals("1", request.details["childrenBornCount"])
assertEquals("2020-05-12", request.details["childBirthDate"])
assertEquals("days", request.quantityUnit)
assertEquals(false, request.affectsAnnualLeaveBalance)
assertEquals("Płatne według zasad ustawowych.", request.financialTreatmentLabel)
assertEquals(listOf("parent_declaration"), request.missingDocumentTypes)
}
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteActionPrerequisiteTest {
@Test
fun finishWaitsUntilEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val pendingStart = action(RouteActionType.Start, RouteActionStatus.Syncing, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Waiting,
routeActionPrerequisiteState(finish, pendingStart),
)
}
@Test
fun finishRunsAfterEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val confirmedStart = action(RouteActionType.Start, RouteActionStatus.Confirmed, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, confirmedStart),
)
}
@Test
fun failedStartBlocksFinishInsteadOfSendingItOutOfOrder() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val failedStart = action(RouteActionType.Start, RouteActionStatus.FailedConflict, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Failed,
routeActionPrerequisiteState(finish, failedStart),
)
}
@Test
fun finishWithoutLocalStartCanUseServerLifecycleState() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, null),
)
}
private fun action(type: String, status: String, createdAt: Long): RouteActionEntity =
RouteActionEntity(
clientActionId = "$type-$createdAt",
routeId = "route-1",
action = type,
weight = 12.5,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
)
}
@@ -0,0 +1,135 @@
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.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class RouteActionServerReconciliationTest {
@Test
fun matchingFinishedRouteConfirmsDuplicateFinishSafely() {
assertTrue(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uszkodzona plomba."),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Uszkodzona plomba.",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun differingNotesKeepConflictVisibleAndPreserveLocalData() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uwagi z telefonu"),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Inna uwaga na serwerze",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun missingServerPhotoDoesNotDiscardLocalFinish() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = null, notes = null),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(status = "ZAKOŃCZONA", weight = null, notes = null),
),
)
}
@Test
fun laterConfirmedFinishSupersedesAnOlderConflictingFinish() {
assertTrue(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Starsza uwaga"),
hasConfirmedFinishAfter = true,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
@Test
fun completedServerRouteAloneDoesNotDiscardConflictingLocalData() {
assertFalse(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Nadal ważna uwaga"),
hasConfirmedFinishAfter = false,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
private fun finish(weight: Double?, notes: String?): RouteActionEntity =
RouteActionEntity(
clientActionId = "finish-1",
routeId = "route-1",
action = RouteActionType.Finish,
weight = weight,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = """["photo-1"]""",
notes = notes,
)
private fun route(
status: String,
weight: Double?,
notes: String?,
photoClientRequestIds: List<String> = emptyList(),
): DriverRouteDto =
DriverRouteDto(
id = "route-1",
routeDate = "2026-07-23",
startsAt = "",
originName = "Baza",
destinationName = "Cel",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza → Cel",
status = status,
driverStatus = status,
unloadingWeight = weight,
unloadingNotes = notes,
distanceKm = 1.0,
notes = null,
truck = null,
photos = photoClientRequestIds.mapIndexed { index, requestId ->
RoutePhotoDto(
id = "photo-$index",
routeId = "route-1",
clientRequestId = requestId,
source = "camera",
stage = "unloading",
mimeType = "image/jpeg",
size = 1,
url = "https://example.test/photo-$index",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
},
)
}
@@ -7,6 +7,10 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRuleSnapshotDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
class DriverLeaveRequestUiRulesTest {
@Test
@@ -18,15 +22,35 @@ class DriverLeaveRequestUiRulesTest {
@Test
fun exposesPolishStatusLabels() {
assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Zatwierdzony", DriverLeaveRequestUiRules.statusLabel("approved"))
assertEquals("Czeka na decyzję", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Zaakceptowany", DriverLeaveRequestUiRules.statusLabel("approved"))
assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected"))
assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Czeka na anulowanie", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled"))
assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked"))
assertEquals("Akceptacja cofnięta", DriverLeaveRequestUiRules.statusLabel("revoked"))
assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status"))
}
@Test
fun explainsAnnualBalanceInDaysAndHours() {
val summary = DriverLeaveSummaryDto(
dailyNormMinutes = 480,
balanceConfigured = true,
pools = listOf(
annualPool(year = 2025, available = 480, reserved = 0, used = 480, granted = 960),
annualPool(year = 2026, available = 3_840, reserved = 960, used = 960, granted = 5_760),
),
)
val totals = DriverLeaveRequestUiRules.annualTotals(summary)
assertEquals(4_320, totals.availableMinutes)
assertEquals(960, totals.reservedMinutes)
assertEquals(1_440, totals.usedMinutes)
assertEquals("9 dni · 72 godz.", DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", 480))
assertEquals("2 dni + 2 godz. · 18 godz.", DriverLeaveRequestUiRules.formatQuantity(1_080, "minutes", 480))
}
@Test
fun exposesPolishEventActionLabels() {
assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted"))
@@ -37,9 +61,78 @@ class DriverLeaveRequestUiRulesTest {
assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested"))
assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved"))
assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected"))
assertEquals("Przywrócono urlop wypoczynkowy", DriverLeaveRequestUiRules.eventActionLabel("annual_leave_interrupted"))
assertEquals("Aktualizacja wniosku", DriverLeaveRequestUiRules.eventActionLabel("some_raw_action"))
}
@Test
fun formatsStatutoryDayLimitsAsDaysInsteadOfMinutes() {
val dayBasedRequest = DriverLeaveRequestDto(
id = "child-care",
dateFrom = "2026-08-03",
dateTo = "2026-08-03",
type = "CHILD_CARE_14",
requestMode = "days",
requestedQuantity = 1,
quantityUnit = "days",
status = "pending",
)
val legacyDayBasedRequest = dayBasedRequest.copy(
id = "force-majeure",
type = "FORCE_MAJEURE",
quantityUnit = null,
rule = DriverLeaveRuleSnapshotDto(
unit = "work_schedule",
poolCode = "FORCE_MAJEURE",
),
)
assertEquals("days", DriverLeaveRequestUiRules.requestQuantityUnit(dayBasedRequest))
assertEquals("1 dzień", DriverLeaveRequestUiRules.formatQuantity(1, "days"))
assertEquals("days", DriverLeaveRequestUiRules.requestQuantityUnit(legacyDayBasedRequest))
}
@Test
fun exposesOtherStatutoryBalancesSeparatelyFromAnnualLeave() {
val summary = DriverLeaveSummaryDto(
pools = listOf(
annualPool(year = 2026, available = 4_800, reserved = 0, used = 0, granted = 4_800),
DriverLeavePoolDto(
id = 31,
poolCode = "CHILD_CARE_14",
year = 2026,
unit = "days",
availableQuantity = 1,
usedQuantity = 1,
grantedQuantity = 2,
status = "active",
),
DriverLeavePoolDto(
id = 32,
poolCode = "CARE_LEAVE",
year = 2026,
unit = "days",
availableQuantity = 5,
grantedQuantity = 5,
status = "active",
),
),
)
val pools = DriverLeaveRequestUiRules.otherEntitlementPools(summary)
assertEquals(listOf("CHILD_CARE_14", "CARE_LEAVE"), pools.map { it.poolCode })
assertEquals("Urlop opiekuńczy", DriverLeaveRequestUiRules.poolLabel("CARE_LEAVE"))
assertEquals("Rodzina i opieka", DriverLeaveRequestUiRules.categoryLabel("family"))
assertTrue(DriverLeaveRequestUiRules.isUsageVisible(summary, "ANNUAL"))
assertFalse(
DriverLeaveRequestUiRules.isUsageVisible(
summary.copy(visibleUsagePoolCodes = emptyList()),
"ANNUAL",
),
)
}
@Test
fun driverCanCancelPendingAndFutureApprovedRequests() {
val tomorrow = LocalDate.now().plusDays(1).toString()
@@ -105,4 +198,23 @@ class DriverLeaveRequestUiRulesTest {
phoneNumber = "+48 600 700 800",
),
)
private fun annualPool(
year: Int,
available: Int,
reserved: Int,
used: Int,
granted: Int,
): DriverLeavePoolDto =
DriverLeavePoolDto(
id = year,
poolCode = "ANNUAL",
year = year,
unit = "minutes",
availableQuantity = available,
reservedQuantity = reserved,
usedQuantity = used,
grantedQuantity = granted,
status = "active",
)
}
@@ -125,16 +125,25 @@ class DriverRouteProjectionTest {
}
@Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
fun conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() {
val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)),
actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.FailedConflict,
weight = 11.8,
notes = "Uszkodzona plomba.",
),
),
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(projection.visibleActions)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
@@ -148,8 +157,9 @@ class DriverRouteProjectionTest {
)
val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals("ZAPLANOWANA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
}
@@ -8,6 +8,8 @@ import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
@@ -16,6 +18,29 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30")
@Test
fun disabledRouteLocationNeverStartsTracking() {
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = false, hasLocationPermission = true))
}
@Test
fun optionalRouteLocationFollowsDriverChoiceAndPermission() {
assertTrue(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = false, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = false))
}
@Test
fun requiredRouteLocationBlocksStartAndWarnsOnlyWhenNeeded() {
assertTrue(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = false))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = true))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Optional, hasLocationPermission = false))
assertTrue(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = true, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = false, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Optional, hasActiveRoute = true, hasLocationPermission = false))
}
@Test
fun allowsPhotoActionsOnlyForTodayRouteDate() {
assertTrue(canManageRoutePhotos("2026-06-30", today))
@@ -387,6 +412,57 @@ class DriverUiRulesTest {
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun showsTachographCardOnlyForCompleteReminderEnabledByBackend() {
assertTrue(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = false,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = false,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(shouldShowTachographReminderCard(null))
}
@Test
fun preselectsOnlySingleTachographConfirmationTask() {
val driverCard = tachographTask()
val vehicle = TachographReminderTaskDto(
id = "vehicle:12:2026-07-31",
type = "vehicle_tachograph",
label = "Tachograf pojazdu",
)
assertEquals(setOf(driverCard.id), defaultTachographConfirmationSelection(listOf(driverCard)))
assertEquals(emptySet<String>(), defaultTachographConfirmationSelection(listOf(driverCard, vehicle)))
}
@Test
fun explainsOfflineStateWithoutCachedSync() {
assertEquals(
@@ -794,4 +870,11 @@ class DriverUiRulesTest {
photo = null,
canUpload = dueToday,
)
private fun tachographTask(): TachographReminderTaskDto =
TachographReminderTaskDto(
id = "driver-card:1:2026-07-31",
type = "driver_card",
label = "Karta kierowcy",
)
}
@@ -43,6 +43,55 @@ class DriverUiStateTest {
assertEquals(ServerConnectionState.Degraded, failed.serverConnectionState)
}
@Test
fun silentRefreshCannotReplaceActiveStartupLoad() {
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = false,
),
)
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Routes,
navigateToRoutes = false,
startupOfflineOnly = false,
startupLoadInProgress = true,
),
)
}
@Test
fun startupAndManualOfflineLoadsRemainAvailable() {
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = true,
startupOfflineOnly = false,
),
)
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = true,
),
)
}
@Test
fun successfulBootstrapAlwaysLeavesInitializingScreen() {
assertEquals(
DriverScreen.Routes,
screenAfterBootstrap(
currentScreen = DriverScreen.Initializing,
navigateToRoutes = false,
leaveRequestsEnabled = true,
),
)
}
@Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState(
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteDatePickerBoundsTest {
@Test
fun usesTheSameMinAndMaxDatesAsTheRouteSelector() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-24"), bounds.selectedDate)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
@Test
fun clampsSelectedDateToAllowedRange() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-30",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-26"), bounds.selectedDate)
}
@Test
fun normalizesAccidentallyReversedServerBounds() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-26",
maxDate = "2026-07-17",
)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
[versions]
agp = "8.7.3"
agp = "8.10.1"
kotlin = "2.1.10"
composeBom = "2026.03.00"
activityCompose = "1.9.3"