Compare commits

..
18 changed files with 1788 additions and 188 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 126 versionCode = 132
versionName = "1.0.73" versionName = "1.0.79"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -84,10 +84,11 @@ class DriverRepository(
type: String, type: String,
requestMode: String, requestMode: String,
requestedQuantity: Int?, requestedQuantity: Int?,
details: Map<String, String>,
): DriverLeaveForecastDto = ): DriverLeaveForecastDto =
api.leaveForecast( api.leaveForecast(
authHeader(requireToken()), authHeader(requireToken()),
LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity), LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity, details),
).data ).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> = suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
@@ -201,6 +202,9 @@ class DriverRepository(
source: String, source: String,
stage: String = "other", stage: String = "other",
metadata: PhotoUploadMetadata = PhotoUploadMetadata(), metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadResponse { ): PhotoUploadResponse {
val resolver = context.contentResolver val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg" val mimeType = resolver.getType(uri) ?: "image/jpeg"
@@ -221,6 +225,9 @@ class DriverRepository(
contentSha256.toPlainTextBody(), contentSha256.toPlainTextBody(),
sourceBody, sourceBody,
stage.toPlainTextBody(), stage.toPlainTextBody(),
workflowVersion?.toPlainTextBody(),
workflowStepId?.toPlainTextBody(),
workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -248,6 +255,9 @@ class DriverRepository(
upload.contentSha256.toPlainTextBody(), upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(), upload.source.toPlainTextBody(),
upload.stage.toPlainTextBody(), upload.stage.toPlainTextBody(),
upload.workflowVersion?.toPlainTextBody(),
upload.workflowStepId?.toPlainTextBody(),
upload.workflowDocumentId?.toPlainTextBody(),
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
metadataParts["longitude"], metadataParts["longitude"],
@@ -216,6 +216,9 @@ interface MobileDriverApi {
@Part("contentSha256") contentSha256: RequestBody, @Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody, @Part("source") source: RequestBody,
@Part("stage") stage: RequestBody?, @Part("stage") stage: RequestBody?,
@Part("workflowVersion") workflowVersion: RequestBody?,
@Part("workflowStepId") workflowStepId: RequestBody?,
@Part("workflowDocumentId") workflowDocumentId: RequestBody?,
@Part("takenAt") takenAt: RequestBody?, @Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?, @Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?, @Part("longitude") longitude: RequestBody?,
@@ -78,6 +78,7 @@ data class DriverAppSettingsDto(
val loadingWeightRequirement: String? = null, val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null, val unloadingPhotoRequirement: String? = null,
val unloadingWeightRequirement: String? = null, val unloadingWeightRequirement: String? = null,
val routeWorkflow: DriverRouteWorkflowDto? = null,
val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null,
@@ -85,9 +86,43 @@ data class DriverAppSettingsDto(
val leaveRequests: LeaveRequestsConfigDto? = null, val leaveRequests: LeaveRequestsConfigDto? = null,
) )
data class DriverRouteWorkflowDto(
val version: String,
val customized: Boolean = false,
val loading: DriverRouteStageWorkflowDto,
val unloading: DriverRouteStageWorkflowDto,
)
data class DriverRouteStageWorkflowDto(
val steps: List<DriverRouteWorkflowStepDto> = emptyList(),
)
data class DriverRouteWorkflowStepDto(
val id: String,
val type: String,
val title: String,
val description: String = "",
val requirement: String = "required",
val documents: List<DriverRouteWorkflowDocumentDto> = emptyList(),
)
data class DriverRouteWorkflowDocumentDto(
val id: String,
val label: String,
val description: String = "",
val requirement: String = "required",
)
data class LeaveRequestsConfigDto( data class LeaveRequestsConfigDto(
val enabled: Boolean = false, val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"), val types: List<String> = listOf("URLOP"),
val usagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
) )
data class DispatchSheetReminderDto( data class DispatchSheetReminderDto(
@@ -236,6 +271,10 @@ data class LeaveRequestTypeDto(
val description: String? = null, val description: String? = null,
val decisionMode: String? = null, val decisionMode: String? = null,
val payrollMode: 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, val legalBasis: String? = null,
) )
@@ -248,6 +287,13 @@ data class DriverLeaveSummaryDto(
val dailyNormMinutes: Int = 480, val dailyNormMinutes: Int = 480,
val balanceConfigured: Boolean = false, val balanceConfigured: Boolean = false,
val overdueQuantity: Int = 0, 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 availableTypes: List<LeaveRequestTypeDto> = emptyList(),
val reminders: List<DriverLeaveReminderDto> = emptyList(), val reminders: List<DriverLeaveReminderDto> = emptyList(),
) )
@@ -279,6 +325,7 @@ data class LeaveForecastBody(
val dateTo: String, val dateTo: String,
val requestMode: String, val requestMode: String,
val requestedQuantity: Int? = null, val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
) )
data class LeaveForecastResponse( data class LeaveForecastResponse(
@@ -287,6 +334,11 @@ data class LeaveForecastResponse(
data class DriverLeaveForecastDto( data class DriverLeaveForecastDto(
val ruleCode: String, 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 quantityUnit: String,
val days: List<DriverLeaveForecastDayDto> = emptyList(), val days: List<DriverLeaveForecastDayDto> = emptyList(),
val workingDaysCount: Int = 0, val workingDaysCount: Int = 0,
@@ -329,6 +381,7 @@ data class DriverLeaveRequestDto(
val typeLabel: String? = null, val typeLabel: String? = null,
val requestMode: String = "days", val requestMode: String = "days",
val requestedQuantity: Int? = null, val requestedQuantity: Int? = null,
val quantityUnit: String? = null,
val rule: DriverLeaveRuleSnapshotDto? = null, val rule: DriverLeaveRuleSnapshotDto? = null,
val details: Map<String, String> = emptyMap(), val details: Map<String, String> = emptyMap(),
val note: String? = null, val note: String? = null,
@@ -344,6 +397,13 @@ data class DriverLeaveRequestDto(
val allocations: List<DriverLeaveAllocationDto> = emptyList(), val allocations: List<DriverLeaveAllocationDto> = emptyList(),
val balanceConfigurationRequired: Boolean = false, val balanceConfigurationRequired: Boolean = false,
val missingDocumentTypes: List<String> = emptyList(), 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( data class DriverLeaveRuleSnapshotDto(
@@ -363,6 +423,10 @@ data class DriverLeaveRuleRequirementsDto(
val requiredDocuments: List<String> = emptyList(), val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(), val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null, val description: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
) )
data class DriverLeaveAllocationDto( data class DriverLeaveAllocationDto(
@@ -486,6 +550,10 @@ data class RoutePhotoDto(
val contentSha256: String? = null, val contentSha256: String? = null,
val source: String, val source: String,
val stage: String = "other", val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val originalName: String? = null,
val mimeType: String?, val mimeType: String?,
val size: Long, val size: Long,
val url: String, val url: String,
@@ -516,6 +584,8 @@ data class StartRouteBody(
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val loadingNotes: String? = null, val loadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
) )
data class FinishRouteBody( data class FinishRouteBody(
@@ -524,6 +594,8 @@ data class FinishRouteBody(
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null, val unloadingNotes: String? = null,
val workflowVersion: String? = null,
val completedStepIds: List<String> = emptyList(),
) )
data class RouteActionResponse( data class RouteActionResponse(
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 7, version = 8,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
@@ -208,6 +208,16 @@ abstract class DriverDatabase : RoomDatabase() {
} }
} }
private val migration7To8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowStepId TEXT")
db.execSQL("ALTER TABLE photo_uploads ADD COLUMN workflowDocumentId TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN workflowVersion TEXT")
db.execSQL("ALTER TABLE route_actions ADD COLUMN completedStepIdsJson TEXT NOT NULL DEFAULT '[]'")
}
}
fun get(context: Context): DriverDatabase = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
@@ -221,6 +231,7 @@ abstract class DriverDatabase : RoomDatabase() {
migration4To5, migration4To5,
migration5To6, migration5To6,
migration6To7, migration6To7,
migration7To8,
).build().also { instance = it } ).build().also { instance = it }
} }
} }
@@ -17,6 +17,9 @@ data class PhotoUploadEntity(
val localPath: String, val localPath: String,
val source: String, val source: String,
val stage: String = "other", val stage: String = "other",
val workflowVersion: String? = null,
val workflowStepId: String? = null,
val workflowDocumentId: String? = null,
val takenAt: String?, val takenAt: String?,
val latitude: Double?, val latitude: Double?,
val longitude: Double?, val longitude: Double?,
@@ -13,7 +13,9 @@ import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -29,12 +31,22 @@ class PhotoUploadOutbox(
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> = fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads() dao.observeQueuedUploads()
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata, stage: String = "other"): PhotoUploadEntity { suspend fun enqueue(
routeId: String,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
stage: String = "other",
workflowVersion: String? = null,
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): PhotoUploadEntity = withContext(Dispatchers.IO) {
val clientRequestId = UUID.randomUUID().toString() val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg" val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) { val extension = when (mimeType) {
"image/png" -> "png" "image/png" -> "png"
"image/webp" -> "webp" "image/webp" -> "webp"
"application/pdf" -> "pdf"
else -> "jpg" else -> "jpg"
} }
val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() } val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() }
@@ -46,6 +58,9 @@ class PhotoUploadOutbox(
localPath = localFile.absolutePath, localPath = localFile.absolutePath,
source = source, source = source,
stage = stage, stage = stage,
workflowVersion = workflowVersion,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
takenAt = metadata.takenAt, takenAt = metadata.takenAt,
latitude = metadata.latitude, latitude = metadata.latitude,
longitude = metadata.longitude, longitude = metadata.longitude,
@@ -58,7 +73,7 @@ class PhotoUploadOutbox(
dao.upsert(upload) dao.upsert(upload)
enqueueWorker(clientRequestId) enqueueWorker(clientRequestId)
return upload upload
} }
suspend fun retry(clientRequestId: String) { suspend fun retry(clientRequestId: String) {
@@ -33,6 +33,8 @@ data class RouteActionEntity(
val weight: Double?, val weight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIdsJson: String, val photoClientRequestIdsJson: String,
val workflowVersion: String? = null,
val completedStepIdsJson: String = "[]",
val status: String = RouteActionStatus.Pending, val status: String = RouteActionStatus.Pending,
val attemptCount: Int = 0, val attemptCount: Int = 0,
val lastError: String? = null, val lastError: String? = null,
@@ -48,16 +48,20 @@ class RouteActionOutbox(
loadingWeight: Double?, loadingWeight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String? = null, notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity = ): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes) enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
suspend fun enqueueFinish( suspend fun enqueueFinish(
routeId: String, routeId: String,
unloadingWeight: Double?, unloadingWeight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String? = null, notes: String? = null,
workflowVersion: String? = null,
completedStepIds: List<String> = emptyList(),
): RouteActionEntity = ): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes) enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes, workflowVersion, completedStepIds)
private suspend fun enqueue( private suspend fun enqueue(
action: String, action: String,
@@ -65,6 +69,8 @@ class RouteActionOutbox(
weight: Double?, weight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String?, notes: String?,
workflowVersion: String?,
completedStepIds: List<String>,
): RouteActionEntity { ): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity( val entity = RouteActionEntity(
@@ -74,6 +80,8 @@ class RouteActionOutbox(
weight = weight, weight = weight,
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
workflowVersion = workflowVersion,
completedStepIdsJson = gson.toJson(completedStepIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() }, notes = notes?.trim()?.takeIf { it.isNotEmpty() },
) )
@@ -181,6 +181,8 @@ class RouteActionWorker(
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes, loadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
), ),
) )
RouteActionType.Finish -> repository.finishRoute( RouteActionType.Finish -> repository.finishRoute(
@@ -191,6 +193,8 @@ class RouteActionWorker(
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes, unloadingNotes = action.notes,
workflowVersion = action.workflowVersion,
completedStepIds = completedStepIds(action),
), ),
) )
else -> error("Nieznana akcja kursu: ${action.action}") else -> error("Nieznana akcja kursu: ${action.action}")
@@ -204,6 +208,14 @@ class RouteActionWorker(
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
private fun completedStepIds(action: RouteActionEntity): List<String> =
runCatching {
gson.fromJson(action.completedStepIdsJson, Array<String>::class.java)?.toList().orEmpty()
}.getOrDefault(emptyList())
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
companion object { companion object {
const val KEY_CLIENT_ACTION_ID = "clientActionId" const val KEY_CLIENT_ACTION_ID = "clientActionId"
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,7 @@ object DriverLeaveRequestUiRules {
"cancellation_rejected" -> "Anulowanie odrzucone" "cancellation_rejected" -> "Anulowanie odrzucone"
"period_amended" -> "Zmieniono termin wniosku" "period_amended" -> "Zmieniono termin wniosku"
"interrupted" -> "Nieobecność została przerwana" "interrupted" -> "Nieobecność została przerwana"
"annual_leave_interrupted" -> "Przywrócono urlop wypoczynkowy"
else -> "Aktualizacja wniosku" else -> "Aktualizacja wniosku"
} }
@@ -77,6 +78,8 @@ object DriverLeaveRequestUiRules {
"AUTHORITY_SUMMONS" -> "Wezwanie urzędowe" "AUTHORITY_SUMMONS" -> "Wezwanie urzędowe"
"RESCUE_DUTIES" -> "Obowiązki ratownicze" "RESCUE_DUTIES" -> "Obowiązki ratownicze"
"MILITARY_DUTIES" -> "Obowiązki wojskowe" "MILITARY_DUTIES" -> "Obowiązki wojskowe"
"ORGAN_DONOR_EXAMS" -> "Badania dawcy"
"CARE_SICKNESS" -> "Opieka nad chorym dzieckiem lub członkiem rodziny"
else -> type else -> type
} }
@@ -103,6 +106,63 @@ object DriverLeaveRequestUiRules {
.filter { it.poolCode == "ANNUAL" && it.status == "active" } .filter { it.poolCode == "ANNUAL" && it.status == "active" }
.sortedByDescending { it.year } .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 { fun formatQuantity(quantity: Int, unit: String, dailyNormMinutes: Int = 480): String {
if (unit == "days") return dayCountLabel(quantity) if (unit == "days") return dayCountLabel(quantity)
@@ -149,6 +209,15 @@ object DriverLeaveRequestUiRules {
else -> "Wniosek zostanie przekazany do osoby, która podejmie decyzję." 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 = fun fieldLabel(field: String): String =
when (field) { when (field) {
"childBirthDate" -> "Data urodzenia dziecka" "childBirthDate" -> "Data urodzenia dziecka"
@@ -165,6 +234,16 @@ object DriverLeaveRequestUiRules {
"examType" -> "Rodzaj egzaminu lub nauki" "examType" -> "Rodzaj egzaminu lub nauki"
"educationProgramKey" -> "Nazwa szkoły lub programu" "educationProgramKey" -> "Nazwa szkoły lub programu"
"classification" -> "Czego dotyczy nieobecność" "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" else -> "Dodatkowa informacja"
} }
@@ -176,6 +255,9 @@ object DriverLeaveRequestUiRules {
"childIdentityKey" -> "Wystarczy imię i rok urodzenia" "childIdentityKey" -> "Wystarczy imię i rok urodzenia"
"educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu" "educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu"
"classification" -> "Krótko opisz powód, aby kadry mogły go prawidłowo zakwalifikować" "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 else -> null
} }
@@ -196,6 +278,9 @@ object DriverLeaveRequestUiRules {
"rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej" "rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej"
"military_document" -> "Dokument wojskowy" "military_document" -> "Dokument wojskowy"
"external_sickness_confirmation" -> "Potwierdzenie niezdolności do pracy" "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" else -> "Wymagany dokument"
} }
@@ -12,6 +12,8 @@ import java.time.temporal.ChronoUnit
import java.util.Locale import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.TachographReminderDto
@@ -278,6 +280,75 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
object RouteWorkflowStepType {
const val Confirmation = "confirmation"
const val Weight = "weight"
const val Photos = "photos"
}
fun routeWorkflowSteps(workflow: DriverRouteWorkflowDto?, stage: String): List<DriverRouteWorkflowStepDto> =
when (stage) {
"loading" -> workflow?.loading?.steps
"unloading" -> workflow?.unloading?.steps
else -> null
}.orEmpty().filter { routeStageRequirementIsVisible(it.requirement) }
fun routeWorkflowSubmitBlocker(
workflow: DriverRouteWorkflowDto?,
stage: String,
completedStepIds: Set<String>,
weightText: String,
serverPhotos: List<RoutePhotoDto>,
localUploads: List<PhotoUploadEntity>,
): String? {
val steps = routeWorkflowSteps(workflow, stage)
if (steps.isEmpty()) return null
val normalizedWeight = parseRouteWeightTons(weightText)
for (step in steps) {
val required = routeStageRequirementIsRequired(step.requirement)
when (step.type) {
RouteWorkflowStepType.Confirmation -> if (required && step.id !in completedStepIds) {
return "Potwierdź krok: ${step.title}."
}
RouteWorkflowStepType.Weight -> when {
required && weightText.isBlank() -> return "Uzupełnij krok: ${step.title}."
weightText.isNotBlank() && normalizedWeight == null -> return "Podaj poprawny tonaż."
weightText.isNotBlank() && normalizedWeight != null && normalizedWeight <= 0.0 -> return "Tonaż musi być większy od zera."
weightText.isNotBlank() && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> return "Tonaż nie może przekraczać 999,999 t."
}
RouteWorkflowStepType.Photos -> if (
required &&
serverPhotos.none { it.workflowDocumentId.isNullOrBlank() } &&
localUploads.none {
it.workflowDocumentId.isNullOrBlank() &&
it.status != PhotoUploadStatus.Cancelled.storageValue &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue
}
) {
return "Dodaj zdjęcie dla kroku: ${step.title}."
}
}
for (document in step.documents.filter { routeStageRequirementIsRequired(it.requirement) }) {
val serverPresent = serverPhotos.any {
it.workflowStepId == step.id && it.workflowDocumentId == document.id
}
val localPresent = localUploads.any {
it.workflowStepId == step.id &&
it.workflowDocumentId == document.id &&
it.status != PhotoUploadStatus.Cancelled.storageValue &&
it.status != PhotoUploadStatus.FailedPermanent.storageValue
}
if (!serverPresent && !localPresent) {
return "Dodaj dokument „${document.label}” w kroku „${step.title}”."
}
}
}
return null
}
fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength) fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength)
fun parseRouteWeightTons(weightText: String): Double? { fun parseRouteWeightTons(weightText: String): Double? {
@@ -39,6 +39,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
@@ -72,6 +73,16 @@ enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Diagnostics
enum class ServerConnectionState { Unknown, Reachable, Degraded } enum class ServerConnectionState { Unknown, Reachable, Degraded }
data class PreparingPhotoUpload(
val id: String,
val routeId: String,
val uri: Uri,
val stage: String,
val workflowStepId: String?,
val workflowDocumentId: String?,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
)
data class DriverUiState( data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true, val loading: Boolean = true,
@@ -98,6 +109,7 @@ data class DriverUiState(
val loadingWeightRequirement: String = RouteStageRequirement.Required, val loadingWeightRequirement: String = RouteStageRequirement.Required,
val unloadingPhotoRequirement: String = RouteStageRequirement.Required, val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
val unloadingWeightRequirement: String = RouteStageRequirement.Required, val unloadingWeightRequirement: String = RouteStageRequirement.Required,
val routeWorkflow: DriverRouteWorkflowDto? = null,
val notifyNewRoutes: Boolean = false, val notifyNewRoutes: Boolean = false,
val routeProgressNotificationEnabled: Boolean = true, val routeProgressNotificationEnabled: Boolean = true,
val notificationPermissionDenied: Boolean = false, val notificationPermissionDenied: Boolean = false,
@@ -105,9 +117,11 @@ data class DriverUiState(
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "", val routeStageWeightText: String = "",
val routeStageNotesText: String = "", val routeStageNotesText: String = "",
val routeStageCompletedStepIds: Set<String> = emptySet(),
val routeActions: List<RouteActionEntity> = emptyList(), val routeActions: List<RouteActionEntity> = emptyList(),
val visibleRouteActions: List<RouteActionEntity> = emptyList(), val visibleRouteActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val preparingPhotoUploads: List<PreparingPhotoUpload> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null, val tachographReminder: TachographReminderDto? = null,
@@ -613,6 +627,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
unloadingWeightRequirement = normalizeRouteStageRequirement(settings?.unloadingWeightRequirement), unloadingWeightRequirement = normalizeRouteStageRequirement(settings?.unloadingWeightRequirement),
routeWorkflow = settings?.routeWorkflow,
leaveRequestsConfig = settings?.leaveRequests?.let { config -> leaveRequestsConfig = settings?.leaveRequests?.let { config ->
LeaveRequestsConfig(enabled = config.enabled, types = config.types) LeaveRequestsConfig(enabled = config.enabled, types = config.types)
}, },
@@ -1259,6 +1274,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
type = snapshot.leaveRequestType, type = snapshot.leaveRequestType,
requestMode = snapshot.leaveRequestMode, requestMode = snapshot.leaveRequestMode,
requestedQuantity = requestedMinutes, requestedQuantity = requestedMinutes,
details = snapshot.leaveRequestDetails,
) )
}.onSuccess { forecast -> }.onSuccess { forecast ->
_state.update { it.copy(leaveForecast = forecast, error = null) } _state.update { it.copy(leaveForecast = forecast, error = null) }
@@ -1477,19 +1493,60 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other") { fun uploadPhoto(
uri: Uri,
source: String,
metadata: PhotoUploadMetadata = PhotoUploadMetadata(),
stage: String = "other",
workflowStepId: String? = null,
workflowDocumentId: String? = null,
): Job? {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return null
if (!routeIsActiveToday(route, snapshot.selectedDate)) { if (!routeIsActiveToday(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") } _state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") }
return return null
} }
viewModelScope.launch { val preparation = PreparingPhotoUpload(
_state.update { it.copy(error = null) } id = UUID.randomUUID().toString(),
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) } routeId = route.id,
.onFailure { throwable -> uri = uri,
_state.update { it.withApiError(throwable) } stage = stage,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
)
_state.update {
it.copy(
preparingPhotoUploads = it.preparingPhotoUploads + preparation,
error = null,
)
}
return viewModelScope.launch {
try {
runCatching {
photoUploadOutbox.enqueue(
routeId = route.id,
uri = uri,
source = source,
metadata = metadata,
stage = stage,
workflowVersion = snapshot.routeWorkflow?.version,
workflowStepId = workflowStepId,
workflowDocumentId = workflowDocumentId,
)
} }
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
} finally {
_state.update {
it.copy(
preparingPhotoUploads = it.preparingPhotoUploads.filterNot { item ->
item.id == preparation.id
},
)
}
}
} }
} }
@@ -1504,7 +1561,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return return
} }
val targetScreen = if ( val targetScreen = if (snapshot.routeWorkflow != null) {
DriverScreen.StartRoute
} else if (
!routeStageRequirementIsVisible(snapshot.loadingPhotoRequirement) && !routeStageRequirementIsVisible(snapshot.loadingPhotoRequirement) &&
routeStageRequirementIsVisible(snapshot.loadingWeightRequirement) routeStageRequirementIsVisible(snapshot.loadingWeightRequirement)
) DriverScreen.LoadingWeight else DriverScreen.StartRoute ) DriverScreen.LoadingWeight else DriverScreen.StartRoute
@@ -1513,6 +1572,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
screen = targetScreen, screen = targetScreen,
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = route.loadingNotes.orEmpty(), routeStageNotesText = route.loadingNotes.orEmpty(),
routeStageCompletedStepIds = emptySet(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -1558,6 +1618,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
screen = DriverScreen.FinishRoute, screen = DriverScreen.FinishRoute,
routeStageWeightText = route.unloadingWeight?.toString().orEmpty(), routeStageWeightText = route.unloadingWeight?.toString().orEmpty(),
routeStageNotesText = route.unloadingNotes.orEmpty(), routeStageNotesText = route.unloadingNotes.orEmpty(),
routeStageCompletedStepIds = emptySet(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -1573,6 +1634,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) } _state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) }
} }
fun toggleRouteStageStep(stepId: String, completed: Boolean) {
_state.update {
it.copy(
routeStageCompletedStepIds = if (completed) {
it.routeStageCompletedStepIds + stepId
} else {
it.routeStageCompletedStepIds - stepId
},
)
}
}
fun correctFailedRouteAction() { fun correctFailedRouteAction() {
val snapshot = _state.value val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions) val action = (snapshot.visibleRouteActions + snapshot.routeActions)
@@ -1671,7 +1744,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val stageUploads = routeStageUsableUploads( val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }, snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
) )
val submitBlocker = if (stage == "loading") { val submitBlocker = if (snapshot.routeWorkflow != null) {
routeWorkflowSubmitBlocker(
workflow = snapshot.routeWorkflow,
stage = stage,
completedStepIds = snapshot.routeStageCompletedStepIds,
weightText = snapshot.routeStageWeightText,
serverPhotos = stagePhotos,
localUploads = stageUploads,
)
} else if (stage == "loading") {
loadingWeightSubmitBlocker( loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText, weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
@@ -1694,7 +1776,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
return return
} }
val photoClientRequestIds = if (routeStageRequirementIsVisible(photoRequirement)) { val photoClientRequestIds = if (snapshot.routeWorkflow != null || routeStageRequirementIsVisible(photoRequirement)) {
(stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId }) (stagePhotos.mapNotNull { it.clientRequestId } + stageUploads.map { it.clientRequestId })
.map { it.trim() } .map { it.trim() }
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
@@ -1713,7 +1795,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { runCatching {
val updatedRoute = when (stage) { val updatedRoute = when (stage) {
"loading" -> { "loading" -> {
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds, notes) routeActionOutbox.enqueueStart(
route.id,
weight,
photoClientRequestIds,
notes,
snapshot.routeWorkflow?.version,
snapshot.routeStageCompletedStepIds.toList(),
)
route.copy( route.copy(
status = "W TRAKCIE", status = "W TRAKCIE",
driverStatus = "W TRAKCIE", driverStatus = "W TRAKCIE",
@@ -1724,7 +1813,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
"unloading" -> { "unloading" -> {
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds, notes) routeActionOutbox.enqueueFinish(
route.id,
weight,
photoClientRequestIds,
notes,
snapshot.routeWorkflow?.version,
snapshot.routeStageCompletedStepIds.toList(),
)
route.copy( route.copy(
status = "ZAKOŃCZONA", status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA", driverStatus = "ZAKOŃCZONA",
@@ -1745,6 +1841,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item },
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = "", routeStageNotesText = "",
routeStageCompletedStepIds = emptySet(),
feedback = if (stage == "loading") { feedback = if (stage == "loading") {
if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu."
} else { } else {
@@ -1954,7 +2051,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.Detail -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
routeActionsJob?.cancel() routeActionsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, routeActions = emptyList(), photoUploads = emptyList(), feedback = null) it.copy(
screen = DriverScreen.Routes,
selectedRoute = null,
routeActions = emptyList(),
photoUploads = emptyList(),
preparingPhotoUploads = emptyList(),
feedback = null,
)
} }
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes) DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Diagnostics -> it.copy(screen = DriverScreen.Profile) DriverScreen.Diagnostics -> it.copy(screen = DriverScreen.Profile)
@@ -0,0 +1,287 @@
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.layout.width
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.rememberCoroutineScope
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.text.style.TextOverflow
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 kotlinx.coroutines.launch
import pl.firmatpp.kierowca.ui.theme.TppTheme
@Composable
internal fun MultiplePhotoCameraScreen(
stage: String,
onPhotoCaptured: suspend (Uri) -> Unit,
onClose: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val coroutineScope = rememberCoroutineScope()
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 pendingUploads 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 && pendingUploads == 0) 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 && pendingUploads == 0,
) {
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 = if (pendingUploads > 0) {
"Wykonano: $capturedCount · zapisuję: $pendingUploads"
} else {
"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),
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(72.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
pendingUploads += 1
coroutineScope.launch {
try {
currentOnPhotoCaptured(cameraFileUri(context, photoFile))
} finally {
pendingUploads = (pendingUploads - 1).coerceAtLeast(0)
}
}
}
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
.align(Alignment.Center)
.size(72.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 && pendingUploads == 0,
modifier = Modifier
.align(Alignment.CenterEnd)
.width(104.dp)
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = TppTheme.colors.forest,
),
shape = RoundedCornerShape(6.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
) {
if (pendingUploads > 0) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp),
)
}
Text(
text = if (pendingUploads > 0) "Zapisuję" else "Gotowe",
color = Color.White,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Clip,
modifier = Modifier.padding(start = 4.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)
@@ -19,6 +19,7 @@ class DriverLeaveApiContractTest {
"dailyNormMinutes": 480, "dailyNormMinutes": 480,
"balanceConfigured": true, "balanceConfigured": true,
"overdueQuantity": 480, "overdueQuantity": 480,
"visibleUsagePoolCodes": ["ANNUAL", "CHILD_CARE_14"],
"pools": [{ "pools": [{
"id": 7, "id": 7,
"poolCode": "ANNUAL", "poolCode": "ANNUAL",
@@ -38,7 +39,9 @@ class DriverLeaveApiContractTest {
"requiredFields": [], "requiredFields": [],
"requiredDocuments": [], "requiredDocuments": [],
"fieldOptions": {}, "fieldOptions": {},
"description": "Urlop rozliczany według grafiku." "description": "Urlop rozliczany według grafiku.",
"financialTreatmentLabel": "Płatny według danych przekazanych przez kadry i płace.",
"affectsAnnualLeaveBalance": true
}] }]
} }
} }
@@ -47,10 +50,12 @@ class DriverLeaveApiContractTest {
) )
assertEquals(480, response.data.dailyNormMinutes) assertEquals(480, response.data.dailyNormMinutes)
assertEquals(listOf("ANNUAL", "CHILD_CARE_14"), response.data.visibleUsagePoolCodes)
assertEquals(7680, response.data.pools.single().availableQuantity) assertEquals(7680, response.data.pools.single().availableQuantity)
assertEquals("URLOP", response.data.availableTypes.single().value) assertEquals("URLOP", response.data.availableTypes.single().value)
assertEquals("ANNUAL", response.data.availableTypes.single().code) assertEquals("ANNUAL", response.data.availableTypes.single().code)
assertTrue("hours" in response.data.availableTypes.single().requestModes) assertTrue("hours" in response.data.availableTypes.single().requestModes)
assertTrue(response.data.availableTypes.single().affectsAnnualLeaveBalance)
} }
@Test @Test
@@ -64,11 +69,14 @@ class DriverLeaveApiContractTest {
"type": "WOLNE", "type": "WOLNE",
"ruleCode": "CHILD_CARE_14", "ruleCode": "CHILD_CARE_14",
"requestMode": "days", "requestMode": "days",
"requestedQuantity": 480, "requestedQuantity": 1,
"quantityUnit": "days",
"details": { "details": {
"childrenBornCount": 1, "childrenBornCount": 1,
"childBirthDate": "2020-05-12" "childBirthDate": "2020-05-12"
}, },
"financialTreatmentLabel": "Płatne według zasad ustawowych.",
"affectsAnnualLeaveBalance": false,
"status": "pending", "status": "pending",
"missingDocumentTypes": ["parent_declaration"] "missingDocumentTypes": ["parent_declaration"]
} }
@@ -78,6 +86,9 @@ class DriverLeaveApiContractTest {
assertEquals("1", request.details["childrenBornCount"]) assertEquals("1", request.details["childrenBornCount"])
assertEquals("2020-05-12", request.details["childBirthDate"]) 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) assertEquals(listOf("parent_declaration"), request.missingDocumentTypes)
} }
} }
@@ -8,6 +8,8 @@ import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto 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 import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
class DriverLeaveRequestUiRulesTest { class DriverLeaveRequestUiRulesTest {
@@ -59,9 +61,78 @@ class DriverLeaveRequestUiRulesTest {
assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested")) assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested"))
assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved")) assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved"))
assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected")) 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")) 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 @Test
fun driverCanCancelPendingAndFutureApprovedRequests() { fun driverCanCancelPendingAndFutureApprovedRequests() {
val tomorrow = LocalDate.now().plusDays(1).toString() val tomorrow = LocalDate.now().plusDays(1).toString()
@@ -6,6 +6,10 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DriverRouteStageWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDocumentDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowDto
import pl.firmatpp.kierowca.data.model.DriverRouteWorkflowStepDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto import pl.firmatpp.kierowca.data.model.TachographReminderDto
@@ -18,6 +22,52 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest { class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30") private val today = LocalDate.parse("2026-06-30")
@Test
fun customWorkflowBlocksUntilConfirmationAndRequiredDocumentArePresent() {
val workflow = DriverRouteWorkflowDto(
version = "workflow-v1",
customized = true,
loading = DriverRouteStageWorkflowDto(
steps = listOf(
DriverRouteWorkflowStepDto(
id = "secure-load",
type = RouteWorkflowStepType.Confirmation,
title = "Sprawdź zabezpieczenie ładunku",
requirement = RouteStageRequirement.Required,
documents = listOf(
DriverRouteWorkflowDocumentDto(
id = "cmr",
label = "List przewozowy CMR",
requirement = RouteStageRequirement.Required,
),
),
),
),
),
unloading = DriverRouteStageWorkflowDto(),
)
assertEquals(
"Potwierdź krok: Sprawdź zabezpieczenie ładunku.",
routeWorkflowSubmitBlocker(workflow, "loading", emptySet(), "", emptyList(), emptyList()),
)
assertEquals(
"Dodaj dokument „List przewozowy CMR” w kroku „Sprawdź zabezpieczenie ładunku”.",
routeWorkflowSubmitBlocker(workflow, "loading", setOf("secure-load"), "", emptyList(), emptyList()),
)
assertEquals(
null,
routeWorkflowSubmitBlocker(
workflow,
"loading",
setOf("secure-load"),
"",
listOf(photo("cmr-photo", "loading").copy(workflowStepId = "secure-load", workflowDocumentId = "cmr")),
emptyList(),
),
)
}
@Test @Test
fun disabledRouteLocationNeverStartsTracking() { fun disabledRouteLocationNeverStartsTracking() {
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true)) assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))