Compare commits

...
5 changed files with 89 additions and 10 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 108 versionCode = 110
versionName = "1.0.55" versionName = "1.0.57"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -228,6 +228,22 @@ fun DriverApp(
viewModel.openLeaveRequest(initialLeaveRequestId) viewModel.openLeaveRequest(initialLeaveRequestId)
} }
} }
LaunchedEffect(state.screen, state.notifyNewRoutes, state.notificationPermissionDenied, appInForeground) {
if (!appInForeground || state.screen != DriverScreen.Profile || !state.notifyNewRoutes) return@LaunchedEffect
if (canPostNotifications(context)) {
if (state.notificationPermissionDenied) {
viewModel.clearNotificationPermissionWarning()
}
return@LaunchedEffect
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPostNotificationsRuntimePermission(context)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.markNotificationPermissionBlocked()
}
}
Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) { Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) {
when (state.screen) { when (state.screen) {
@@ -2238,13 +2254,18 @@ private fun ProfileScreen(
Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall) Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall)
} }
if (state.notificationPermissionDenied) { if (state.notificationPermissionDenied) {
Text(
"Android blokuje powiadomienia dla tej aplikacji. Token FCM może być zarejestrowany, ale powiadomienie nie pojawi się na telefonie, dopóki nie włączysz zgody systemowej.",
color = TppTheme.colors.error,
style = MaterialTheme.typography.bodySmall,
)
Button( Button(
onClick = onOpenNotificationSettings, onClick = onOpenNotificationSettings,
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
) { ) {
Text("Przejdź do ustawień", fontWeight = FontWeight.Bold) Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold)
} }
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
@@ -2725,16 +2746,14 @@ private fun RouteStageScreen(
label = { Text(weightLabel) }, label = { Text(weightLabel) },
trailingIcon = { trailingIcon = {
Text( Text(
"t", "t / kg",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
}, },
supportingText = { supportingText = {
if (weightBlocker != null) { Text(weightBlocker ?: routeWeightInputHelpText(state.routeStageWeightText))
Text(weightBlocker)
}
}, },
isError = weightBlocker != null, isError = weightBlocker != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
@@ -1,11 +1,15 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.io.File import java.io.File
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
@@ -15,6 +19,8 @@ import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val routeWeightKilogramInputThreshold = BigDecimal("1000")
private const val routeWeightMaxTons = 999.999
enum class RouteFlowStepState { enum class RouteFlowStepState {
Todo, Todo,
@@ -150,6 +156,31 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
fun parseRouteWeightTons(weightText: String): Double? {
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
enteredWeight.movePointLeft(3)
} else {
enteredWeight
}
return tons.setScale(3, RoundingMode.HALF_UP).toDouble()
}
fun routeWeightInputHelpText(weightText: String): String {
val enteredWeight = weightText.trim().replace(',', '.').toDoubleOrNull()
val tons = parseRouteWeightTons(weightText)
val enteredAsKilograms = enteredWeight != null &&
abs(enteredWeight) >= routeWeightKilogramInputThreshold.toDouble() &&
tons != null
return if (enteredAsKilograms) {
"Wpisano kilogramy — zapiszemy ${String.format(Locale("pl", "PL"), "%.3f", tons)} t."
} else {
"Wpisz tony (np. 23,4). Wartości od 1000 rozpoznajemy jako kilogramy (np. 23400)."
}
}
fun canSubmitRouteStageForm( fun canSubmitRouteStageForm(
weightText: String, weightText: String,
serverPhotoCount: Int, serverPhotoCount: Int,
@@ -188,13 +219,14 @@ fun loadingWeightSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku." loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null else -> null
} }
@@ -208,13 +240,14 @@ fun routeStageSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu." routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null else -> null
} }
@@ -639,6 +639,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun markNotificationPermissionBlocked() {
_state.update {
it.copy(
notificationPermissionDenied = true,
error = "Powiadomienia są włączone w aplikacji, ale Android blokuje ich wyświetlanie. Włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun clearNotificationPermissionWarning() {
_state.update {
it.copy(
notificationPermissionDenied = false,
error = if (it.error?.contains("powiadom", ignoreCase = true) == true) null else it.error,
)
}
}
fun setThemeMode(themeMode: AppThemeMode) { fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) } _state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch { viewModelScope.launch {
@@ -1085,7 +1103,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement
val weight = if (routeStageRequirementIsVisible(weightRequirement)) { val weight = if (routeStageRequirementIsVisible(weightRequirement)) {
snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() parseRouteWeightTons(snapshot.routeStageWeightText)
} else { } else {
null null
} }
@@ -306,11 +306,20 @@ class DriverUiRulesTest {
fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() { fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() {
assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0)) assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0))
assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1)) assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1))
assertTrue(canSubmitRouteStageForm(weightText = "23400", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0))
} }
@Test
fun parsesRouteWeightsEnteredInTonsOrKilograms() {
assertEquals(23.4, parseRouteWeightTons("23,4") ?: 0.0, 0.0)
assertEquals(23.4, parseRouteWeightTons("23400") ?: 0.0, 0.0)
assertEquals(23.401, parseRouteWeightTons("23400,5") ?: 0.0, 0.0)
assertEquals("Wpisano kilogramy — zapiszemy 23,400 t.", routeWeightInputHelpText("23400"))
}
@Test @Test
fun filtersRoutePhotosByStageWithOtherFallback() { fun filtersRoutePhotosByStageWithOtherFallback() {
val photos = listOf( val photos = listOf(