Compare commits

...
8 changed files with 113 additions and 16 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 = 107 versionCode = 110
versionName = "1.0.54" versionName = "1.0.57"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -117,6 +117,7 @@ data class RealtimeConfigDto(
val reverbEnabled: Boolean = false, val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null, val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null, val reverbWsBaseUrl: String? = null,
val reverbOrigin: String? = null,
) )
data class RoutesBucketDto( data class RoutesBucketDto(
@@ -43,17 +43,17 @@ class DriverRepositoryLiveSyncGateway(
} }
interface LiveWebSocketFactory { interface LiveWebSocketFactory {
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket
} }
class OkHttpLiveWebSocketFactory( class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient, private val client: OkHttpClient,
) : LiveWebSocketFactory { ) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket = override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket =
client.newWebSocket( client.newWebSocket(
Request.Builder() Request.Builder()
.url(url) .url(url)
.header("Origin", websocketOrigin(url)) .header("Origin", origin?.takeIf { it.isNotBlank() } ?: websocketOrigin(url))
.build(), .build(),
listener, listener,
) )
@@ -248,7 +248,7 @@ class DriverLiveSyncClient(
} }
private fun connectNow(resetAttempt: Boolean = false) { private fun connectNow(resetAttempt: Boolean = false) {
val wsUrl = synchronized(lock) { val (wsUrl, origin) = synchronized(lock) {
val config = realtimeConfig ?: return val config = realtimeConfig ?: return
val id = driverId ?: return val id = driverId ?: return
if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return
@@ -262,11 +262,15 @@ class DriverLiveSyncClient(
val appKey = config.reverbAppKey.orEmpty() val appKey = config.reverbAppKey.orEmpty()
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty() val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false" Pair(
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false",
config.reverbOrigin,
)
} }
val socket = webSocketFactory.newWebSocket( val socket = webSocketFactory.newWebSocket(
wsUrl, wsUrl,
origin,
object : WebSocketListener() { object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text) handleMessage(webSocket, text)
@@ -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
} }
@@ -23,6 +23,7 @@ class DriverLiveSyncClientTest {
reverbEnabled = true, reverbEnabled = true,
reverbAppKey = "app-key", reverbAppKey = "app-key",
reverbWsBaseUrl = "wss://example.test/app", reverbWsBaseUrl = "wss://example.test/app",
reverbOrigin = "https://bootstrap-origin.test",
) )
@Test @Test
@@ -32,6 +33,16 @@ class DriverLiveSyncClientTest {
assertEquals("http://localhost:8080", websocketOrigin("ws://localhost:8080/app/app-key")) assertEquals("http://localhost:8080", websocketOrigin("ws://localhost:8080/app/app-key"))
} }
@Test
fun passesBootstrapOriginToWebSocketFactory() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
assertEquals("https://bootstrap-origin.test", factory.origins.single())
}
@Test @Test
fun reconnectsWithBackoffAfterSocketFailure() = runTest { fun reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
@@ -211,10 +222,12 @@ class DriverLiveSyncClientTest {
private class FakeWebSocketFactory : LiveWebSocketFactory { private class FakeWebSocketFactory : LiveWebSocketFactory {
val sockets = mutableListOf<FakeWebSocket>() val sockets = mutableListOf<FakeWebSocket>()
val origins = mutableListOf<String?>()
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket { override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket {
val socket = FakeWebSocket(listener) val socket = FakeWebSocket(listener)
sockets += socket sockets += socket
origins += origin
return socket return socket
} }
} }
@@ -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(