Compare commits

..
9 changed files with 647 additions and 110 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 36
versionCode = 127
versionName = "1.0.74"
versionCode = 130
versionName = "1.0.77"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -88,6 +88,13 @@ data class DriverAppSettingsDto(
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(
@@ -252,6 +259,13 @@ data class DriverLeaveSummaryDto(
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(),
)
@@ -13,7 +13,9 @@ import java.io.File
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -29,7 +31,13 @@ class PhotoUploadOutbox(
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
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",
): PhotoUploadEntity = withContext(Dispatchers.IO) {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
@@ -58,7 +66,7 @@ class PhotoUploadOutbox(
dao.upsert(upload)
enqueueWorker(clientRequestId)
return upload
upload
}
suspend fun retry(clientRequestId: String) {
@@ -190,6 +190,11 @@ private enum class RouteLocationPermissionPurpose {
RestoreRequiredTracking,
}
private enum class RouteStageCameraMode {
Single,
Multiple,
}
@Composable
fun DriverApp(
viewModel: DriverViewModel,
@@ -464,6 +469,9 @@ fun DriverApp(
locationPermissionGranted = locationPermissionGranted,
onBack = viewModel::back,
onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onRefresh = viewModel::refreshSelectedRoute,
onStartRoute = openStartRoute,
onFinishRoute = viewModel::openFinishRoute,
@@ -483,8 +491,9 @@ fun DriverApp(
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") },
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading")?.join() },
onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute,
@@ -504,6 +513,7 @@ fun DriverApp(
onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { _, _, _ -> },
onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitStartRoute,
@@ -521,8 +531,9 @@ fun DriverApp(
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") },
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading")?.join() },
onPhoto = viewModel::openPhoto,
onDeletePhoto = viewModel::deletePhoto,
onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitFinishRoute,
@@ -1547,6 +1558,12 @@ private fun LeaveBalanceOverview(state: DriverUiState) {
val summary = state.leaveSummary
val totals = DriverLeaveRequestUiRules.annualTotals(summary)
val norm = summary?.dailyNormMinutes ?: 480
val showAnnualUsage = DriverLeaveRequestUiRules.isUsageVisible(summary, "ANNUAL")
val otherPools = DriverLeaveRequestUiRules.otherEntitlementPools(summary)
if (summary != null && !showAnnualUsage && otherPools.isEmpty()) {
return
}
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -1555,99 +1572,107 @@ private fun LeaveBalanceOverview(state: DriverUiState) {
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("Twój urlop wypoczynkowy", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(
if (showAnnualUsage) "Twój urlop wypoczynkowy" else "Twoje limity nieobecności",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
when {
summary == null -> {
Text("Pobieramy aktualne saldo…", color = TppTheme.colors.muted)
}
!summary.balanceConfigured -> {
Text(
"Kadry przygotowują Twoje saldo",
color = Color(0xFF7A5A00),
fontWeight = FontWeight.Bold,
)
Text(
"Możesz zapisać wniosek, ale jego godziny zostaną odjęte dopiero po potwierdzeniu puli urlopu.",
color = TppTheme.colors.muted,
)
}
else -> {
Text("Pozostało do wykorzystania", color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold)
Text(
DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", norm),
color = TppTheme.colors.forest,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
)
val denominator = (totals.grantedMinutes).coerceAtLeast(1)
LinearProgressIndicator(
progress = { (totals.usedMinutes.toFloat() / denominator).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(7.dp),
color = TppTheme.colors.forest,
trackColor = TppTheme.colors.successContainer,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
LeaveBalanceStat(
title = "Wykorzystano",
value = DriverLeaveRequestUiRules.formatQuantity(totals.usedMinutes, "minutes", norm),
modifier = Modifier.weight(1f),
)
LeaveBalanceStat(
title = "W przyszłych urlopach",
value = DriverLeaveRequestUiRules.formatQuantity(totals.reservedMinutes, "minutes", norm),
modifier = Modifier.weight(1f),
)
if (showAnnualUsage) {
when {
summary == null -> {
Text("Pobieramy aktualne saldo…", color = TppTheme.colors.muted)
}
}
}
if (totals.overdueMinutes > 0) {
Row(
Modifier
.fillMaxWidth()
.background(TppTheme.colors.warningContainer, RoundedCornerShape(6.dp))
.border(1.dp, TppTheme.colors.warningOutline, RoundedCornerShape(6.dp))
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top,
) {
Icon(Icons.Outlined.Info, contentDescription = null, tint = Color(0xFF7A5A00))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text("Masz zaległy urlop", color = Color(0xFF7A5A00), fontWeight = FontWeight.Bold)
!summary.balanceConfigured -> {
Text(
"${DriverLeaveRequestUiRules.formatQuantity(totals.overdueMinutes, "minutes", norm)} zaplanuj możliwie szybko, najpóźniej do 30 września. Saldo nie zniknie automatycznie.",
"Kadry przygotowują Twoje saldo",
color = Color(0xFF7A5A00),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
)
Text(
"Możesz zapisać wniosek, ale jego godziny zostaną odjęte dopiero po potwierdzeniu puli urlopu.",
color = TppTheme.colors.muted,
)
}
else -> {
Text("Pozostało do wykorzystania", color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold)
Text(
DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", norm),
color = TppTheme.colors.forest,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
)
val denominator = (totals.grantedMinutes).coerceAtLeast(1)
LinearProgressIndicator(
progress = { (totals.usedMinutes.toFloat() / denominator).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().height(7.dp),
color = TppTheme.colors.forest,
trackColor = TppTheme.colors.successContainer,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
LeaveBalanceStat(
title = "Wykorzystano",
value = DriverLeaveRequestUiRules.formatQuantity(totals.usedMinutes, "minutes", norm),
modifier = Modifier.weight(1f),
)
LeaveBalanceStat(
title = "W przyszłych urlopach",
value = DriverLeaveRequestUiRules.formatQuantity(totals.reservedMinutes, "minutes", norm),
modifier = Modifier.weight(1f),
)
}
}
}
if (totals.overdueMinutes > 0) {
Row(
Modifier
.fillMaxWidth()
.background(TppTheme.colors.warningContainer, RoundedCornerShape(6.dp))
.border(1.dp, TppTheme.colors.warningOutline, RoundedCornerShape(6.dp))
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top,
) {
Icon(Icons.Outlined.Info, contentDescription = null, tint = Color(0xFF7A5A00))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text("Masz zaległy urlop", color = Color(0xFF7A5A00), fontWeight = FontWeight.Bold)
Text(
"${DriverLeaveRequestUiRules.formatQuantity(totals.overdueMinutes, "minutes", norm)} zaplanuj możliwie szybko, najpóźniej do 30 września. Saldo nie zniknie automatycznie.",
color = Color(0xFF7A5A00),
fontSize = 13.sp,
)
}
}
}
DriverLeaveRequestUiRules.annualPools(summary).forEach { pool ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
if (pool.year < LocalDate.now().year) "Urlop zaległy z ${pool.year} r." else "Pula na ${pool.year} r.",
color = TppTheme.colors.muted,
)
Text(
DriverLeaveRequestUiRules.formatQuantity(pool.availableQuantity, pool.unit, norm),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
)
}
}
}
DriverLeaveRequestUiRules.annualPools(summary).forEach { pool ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (otherPools.isNotEmpty()) {
if (showAnnualUsage) {
HorizontalDivider(color = TppTheme.colors.outline)
Text(
if (pool.year < LocalDate.now().year) "Urlop zaległy z ${pool.year} r." else "Pula na ${pool.year} r.",
color = TppTheme.colors.muted,
)
Text(
DriverLeaveRequestUiRules.formatQuantity(pool.availableQuantity, pool.unit, norm),
"Pozostałe dostępne limity",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
fontSize = 17.sp,
)
}
}
val otherPools = DriverLeaveRequestUiRules.otherEntitlementPools(summary)
if (otherPools.isNotEmpty()) {
HorizontalDivider(color = TppTheme.colors.outline)
Text(
"Pozostałe dostępne limity",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
)
Text(
"Te limity są rozliczane osobno i nie zmniejszają zwykłego urlopu wypoczynkowego.",
color = TppTheme.colors.muted,
@@ -4402,8 +4427,9 @@ private fun RouteStageScreen(
onBack: () -> Unit,
onWeightChange: (String) -> Unit,
onNotesChange: (String) -> Unit,
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onUpload: suspend (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
onSubmit: () -> Unit,
@@ -4411,6 +4437,9 @@ private fun RouteStageScreen(
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var pendingCameraMode by remember { mutableStateOf<RouteStageCameraMode?>(null) }
var showMultiplePhotoCamera by remember(stage) { mutableStateOf(false) }
var multiplePhotoLocationMetadata by remember(stage) { mutableStateOf(PhotoUploadMetadata()) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) }
val route = state.displaySelectedRoute
@@ -4464,7 +4493,15 @@ private fun RouteStageScreen(
}
cameraUri = null
}
val openMultiplePhotoCamera = {
multiplePhotoLocationMetadata = PhotoUploadMetadata()
showMultiplePhotoCamera = true
coroutineScope.launch {
multiplePhotoLocationMetadata = cameraPhotoMetadata(context).copy(takenAt = null)
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val requestedMode = pendingCameraMode
val pendingUri = cameraUri
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
@@ -4472,12 +4509,47 @@ private fun RouteStageScreen(
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
showPreciseLocationPermissionDialog = true
cameraUri = null
pendingCameraMode = null
return@rememberLauncherForActivityResult
}
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri)
if (cameraGranted) {
when (requestedMode) {
RouteStageCameraMode.Single -> {
if (pendingUri != null) cameraLauncher.launch(pendingUri)
}
RouteStageCameraMode.Multiple -> {
openMultiplePhotoCamera()
}
null -> Unit
}
} else {
cameraUri = null
}
pendingCameraMode = null
}
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata())
if (uri != null) {
coroutineScope.launch {
onUpload(uri, "gallery", PhotoUploadMetadata())
}
}
}
if (showMultiplePhotoCamera) {
MultiplePhotoCameraScreen(
stage = stage,
onPhotoCaptured = { uri ->
onUpload(
uri,
"camera",
multiplePhotoLocationMetadata.copy(takenAt = Instant.now().toString()),
)
},
onClose = {
showMultiplePhotoCamera = false
},
)
return
}
BoxWithConstraints(Modifier.fillMaxSize()) {
@@ -4559,19 +4631,37 @@ private fun RouteStageScreen(
if (showPhotoActions) {
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) {
val newUri = createCameraUri(context)
cameraUri = newUri
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(missingPermissions)
}
}
RouteStageCameraActions(
onSinglePhoto = {
val newUri = createCameraUri(context)
cameraUri = newUri
pendingCameraMode = RouteStageCameraMode.Single
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
pendingCameraMode = null
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(missingPermissions)
}
},
onMultiplePhotos = {
cameraUri = null
pendingCameraMode = RouteStageCameraMode.Multiple
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
pendingCameraMode = null
openMultiplePhotoCamera()
} else {
cameraPermissionLauncher.launch(missingPermissions)
}
},
)
if (state.allowGalleryUploads) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) {
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
@@ -4588,7 +4678,7 @@ private fun RouteStageScreen(
deletingPhotoIds = state.deletingPhotoIds,
imageAuthHeader = state.imageAuthHeader,
onPhoto = onPhoto,
onDeletePhoto = {},
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
@@ -4795,6 +4885,9 @@ private fun DetailScreen(
locationPermissionGranted: Boolean,
onBack: () -> Unit,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
onRefresh: () -> Unit,
onStartRoute: () -> Unit,
onFinishRoute: () -> Unit,
@@ -4853,6 +4946,9 @@ private fun DetailScreen(
loadingWeightPending = projection.loadingWeightPending,
unloadingWeightPending = projection.unloadingWeightPending,
onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
}
item { ErrorText(state.error) }
@@ -5321,6 +5417,9 @@ private fun CargoDocumentationSection(
loadingWeightPending: Boolean,
unloadingWeightPending: Boolean,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
) {
val loadingPhotos = routePhotosForStage(route.photos, "loading")
val unloadingPhotos = routePhotosForStage(route.photos, "unloading")
@@ -5345,6 +5444,9 @@ private fun CargoDocumentationSection(
imageAuthHeader = imageAuthHeader,
weightPending = loadingWeightPending,
onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
RouteStageDocumentationBlock(
title = "Rozładunek",
@@ -5356,6 +5458,9 @@ private fun CargoDocumentationSection(
imageAuthHeader = imageAuthHeader,
weightPending = unloadingWeightPending,
onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
if (otherPhotos.isNotEmpty() || otherUploads.isNotEmpty()) {
RouteStageDocumentationBlock(
@@ -5368,6 +5473,9 @@ private fun CargoDocumentationSection(
imageAuthHeader = imageAuthHeader,
weightPending = false,
onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
}
}
@@ -5384,6 +5492,9 @@ private fun RouteStageDocumentationBlock(
imageAuthHeader: String?,
weightPending: Boolean = false,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
) {
val count = visiblePhotoAttachmentCount(photos.size, uploads.size)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -5466,10 +5577,9 @@ private fun RouteStageDocumentationBlock(
deletingPhotoIds = deletingPhotoIds,
imageAuthHeader = imageAuthHeader,
onPhoto = onPhoto,
onDeletePhoto = {},
onDeleteUpload = {},
onRetryUpload = {},
readOnly = true,
onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
}
}
@@ -5478,6 +5588,107 @@ private fun RouteStageDocumentationBlock(
private fun formatRouteWeight(weight: Double): String =
if (weight % 1.0 == 0.0) "${weight.toInt()} t" else String.format(Locale("pl", "PL"), "%.3f t", weight)
@Composable
private fun RouteStageCameraActions(
onSinglePhoto: () -> Unit,
onMultiplePhotos: () -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 300.dp) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RouteStageCameraActionButton(
label = "Jedno zdjęcie",
icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.forest,
compact = true,
modifier = Modifier.weight(1f),
onClick = onSinglePhoto,
)
RouteStageCameraActionButton(
label = "Wiele zdjęć",
icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.forest,
compact = true,
modifier = Modifier.weight(1f),
onClick = onMultiplePhotos,
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
RouteStageCameraActionButton(
label = "Jedno zdjęcie",
icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.forest,
compact = false,
modifier = Modifier.fillMaxWidth(),
onClick = onSinglePhoto,
)
RouteStageCameraActionButton(
label = "Wiele zdjęć",
icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.forest,
compact = false,
modifier = Modifier.fillMaxWidth(),
onClick = onMultiplePhotos,
)
}
}
}
}
@Composable
private fun RouteStageCameraActionButton(
label: String,
icon: ImageVector,
color: Color,
compact: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
modifier = modifier.height(if (compact) 76.dp else 64.dp),
colors = ButtonDefaults.buttonColors(containerColor = color),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
) {
if (compact) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
}
} else {
Icon(icon, contentDescription = null, tint = Color.White)
Spacer(Modifier.width(10.dp))
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) {
Button(
@@ -106,6 +106,9 @@ object DriverLeaveRequestUiRules {
.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" }
@@ -1478,14 +1478,19 @@ 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",
): Job? {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
val route = snapshot.selectedRoute ?: return null
if (!routeIsActiveToday(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") }
return
return null
}
viewModelScope.launch {
return viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) }
.onFailure { throwable ->
@@ -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,
"balanceConfigured": true,
"overdueQuantity": 480,
"visibleUsagePoolCodes": ["ANNUAL", "CHILD_CARE_14"],
"pools": [{
"id": 7,
"poolCode": "ANNUAL",
@@ -49,6 +50,7 @@ class DriverLeaveApiContractTest {
)
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)
@@ -124,6 +124,13 @@ class DriverLeaveRequestUiRulesTest {
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