Compare commits

...
13 changed files with 451 additions and 28 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 = 111
versionName = "1.0.54" versionName = "1.0.58"
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(
@@ -70,6 +70,9 @@ class DriverSyncRepository(
) )
} }
suspend fun hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? { suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null val cached = dao.route(routeId) ?: return null
return CachedValue( return CachedValue(
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
class StartupOfflineFallbackLoader(
private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS,
) {
suspend fun <T> load(
onlineLoad: suspend () -> T,
offlineLoad: suspend () -> T?,
): T {
val onlineValue = withTimeoutOrNull(timeoutMillis) {
onlineLoad()
}
if (onlineValue != null) {
return onlineValue
}
return offlineLoad()
?: throw IOException(
"Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " +
"i w telefonie nie ma zapisanych danych offline.",
)
}
}
@@ -0,0 +1,51 @@
package pl.firmatpp.kierowca.domain
data class StartupSessionMessage(
val title: String,
val detail: String,
)
object StartupSessionPolicy {
const val OFFLINE_BUTTON_DELAY_SECONDS = 4
const val OFFLINE_FALLBACK_SECONDS = 15
const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L
fun remainingSeconds(elapsedSeconds: Int): Int =
(OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0)
fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean =
offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS
fun message(elapsedSeconds: Int): StartupSessionMessage = when {
elapsedSeconds < 3 -> StartupSessionMessage(
title = "Sprawdzamy zapisaną sesję",
detail = "Odczytujemy bezpieczne logowanie kierowcy.",
)
elapsedSeconds < 6 -> StartupSessionMessage(
title = "Potwierdzamy logowanie",
detail = "Łączymy telefon z serwerem.",
)
elapsedSeconds < 9 -> StartupSessionMessage(
title = "Pobieramy aktualne kursy",
detail = "Synchronizujemy plan dnia i ustawienia aplikacji.",
)
elapsedSeconds < 12 -> StartupSessionMessage(
title = "To trwa dłużej niż zwykle",
detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.",
)
else -> StartupSessionMessage(
title = "Przygotowujemy tryb offline",
detail = "Za chwilę pokażemy ostatnie zapisane kursy.",
)
}
fun countdownLabel(elapsedSeconds: Int): String {
val remaining = remainingSeconds(elapsedSeconds)
return if (remaining > 0) {
"Tryb offline najpóźniej za $remaining s"
} else {
"Otwieramy zapisane dane offline"
}
}
}
@@ -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)
@@ -12,9 +12,12 @@ import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
@@ -115,6 +118,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -159,6 +163,7 @@ import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity
import pl.firmatpp.kierowca.sync.LiveSyncConnectionState import pl.firmatpp.kierowca.sync.LiveSyncConnectionState
import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.AppThemeMode
import pl.firmatpp.kierowca.ui.theme.TppTheme import pl.firmatpp.kierowca.ui.theme.TppTheme
@@ -228,10 +233,29 @@ 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) {
DriverScreen.Initializing -> StartupScreen() DriverScreen.Initializing -> StartupScreen(
offlineDataAvailable = state.startupOfflineAvailable,
onUseOfflineNow = viewModel::useOfflineStartupDataNow,
)
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp) DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back) DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen( DriverScreen.Routes -> RoutesScreen(
@@ -376,7 +400,21 @@ fun DriverApp(
} }
@Composable @Composable
private fun StartupScreen() { private fun StartupScreen(
offlineDataAvailable: Boolean,
onUseOfflineNow: () -> Unit,
) {
var elapsedSeconds by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (elapsedSeconds < StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS) {
delay(1_000L)
elapsedSeconds += 1
}
}
val sessionMessage = StartupSessionPolicy.message(elapsedSeconds)
val countdownLabel = StartupSessionPolicy.countdownLabel(elapsedSeconds)
val showOfflineButton = StartupSessionPolicy.shouldShowOfflineButton(elapsedSeconds, offlineDataAvailable)
BoxWithConstraints( BoxWithConstraints(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -401,13 +439,100 @@ private fun StartupScreen() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
CircularProgressIndicator(color = TppTheme.colors.forest) CircularProgressIndicator(color = TppTheme.colors.forest)
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(22.dp))
Text( Text(
"Sprawdzanie sesji", "URUCHAMIAMY APLIKACJĘ",
color = TppTheme.colors.muted, color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
fontSize = 12.sp,
) )
Spacer(Modifier.height(10.dp))
AnimatedContent(
targetState = sessionMessage,
transitionSpec = {
fadeIn(animationSpec = tween(260)) togetherWith
fadeOut(animationSpec = tween(220))
},
label = "startup-session-message",
) { message ->
Column(
modifier = Modifier.heightIn(min = 72.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
Text(
message.title,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(6.dp))
Text(
message.detail,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.muted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
Spacer(Modifier.height(14.dp))
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.panel),
border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)),
shape = RoundedCornerShape(6.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = countdownLabel,
transitionSpec = {
fadeIn(animationSpec = tween(180)) togetherWith
fadeOut(animationSpec = tween(140))
},
label = "startup-offline-countdown",
) { label ->
Text(
label,
color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Jeśli kursy były wcześniej zapisane w telefonie.",
color = TppTheme.colors.muted,
fontSize = 12.sp,
)
}
}
AnimatedVisibility(
visible = showOfflineButton,
enter = fadeIn(animationSpec = tween(260)),
exit = fadeOut(animationSpec = tween(160)),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onUseOfflineNow,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
border = BorderStroke(1.dp, TppTheme.colors.forest),
colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.forest),
shape = RoundedCornerShape(6.dp),
) {
Text(
"Przejdź do trybu offline teraz",
fontWeight = FontWeight.Bold,
)
}
}
}
} }
} }
} }
@@ -2238,13 +2363,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 +2855,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
} }
@@ -22,6 +22,7 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
@@ -61,6 +62,7 @@ data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true, val loading: Boolean = true,
val refreshing: Boolean = false, val refreshing: Boolean = false,
val startupOfflineAvailable: Boolean = false,
val phone: String = "", val phone: String = "",
val otpCode: String = "", val otpCode: String = "",
val maskedPhone: String = "", val maskedPhone: String = "",
@@ -188,6 +190,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val appPreferencesStore = AppPreferencesStore(application) private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application) private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository) private val syncRepository = DriverSyncRepository(application, repository)
private val startupOfflineFallbackLoader = StartupOfflineFallbackLoader()
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application) private val photoUploadOutbox = PhotoUploadOutbox(application)
private val routeActionOutbox = RouteActionOutbox(application) private val routeActionOutbox = RouteActionOutbox(application)
@@ -215,6 +218,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var leaveCalendarRequestGeneration: Long = 0 private var leaveCalendarRequestGeneration: Long = 0
private var routesRequestGeneration: Long = 0 private var routesRequestGeneration: Long = 0
private var routeDetailRequestGeneration: Long = 0 private var routeDetailRequestGeneration: Long = 0
private var routesLoadJob: Job? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
init { init {
@@ -295,6 +299,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
viewModelScope.launch { viewModelScope.launch {
if (repository.hasToken()) { if (repository.hasToken()) {
val offlineAvailable = syncRepository.hasBootstrapCache(_state.value.selectedDate)
_state.update { it.copy(startupOfflineAvailable = offlineAvailable) }
refreshRoutes() refreshRoutes()
} else { } else {
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) } _state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
@@ -338,6 +344,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun useOfflineStartupDataNow() {
val snapshot = _state.value
if (snapshot.screen != DriverScreen.Initializing || !snapshot.startupOfflineAvailable) {
return
}
loadRoutes(
date = snapshot.selectedDate,
showLoading = true,
navigateToRoutes = true,
startupOfflineOnly = true,
)
}
fun onAppForegrounded() { fun onAppForegrounded() {
DriverRuntimeSyncState.foreground = true DriverRuntimeSyncState.foreground = true
liveSyncClient.setForeground(true) liveSyncClient.setForeground(true)
@@ -365,17 +385,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeDayLiveUpdateMessage = null) } _state.update { it.copy(routeDayLiveUpdateMessage = null) }
} }
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { private fun loadRoutes(
date: String?,
showLoading: Boolean,
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean = false,
) {
val generation = ++routesRequestGeneration val generation = ++routesRequestGeneration
viewModelScope.launch { routesLoadJob?.cancel()
routesLoadJob = viewModelScope.launch {
val isStartup = _state.value.screen == DriverScreen.Initializing
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) } _state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching { runCatching {
val cached = if (_state.value.isOnline) { val cached = when {
syncRepository.bootstrap(date) startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
} else {
syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.") ?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
isStartup -> {
startupOfflineFallbackLoader.load(
onlineLoad = { syncRepository.bootstrap(date) },
offlineLoad = { syncRepository.bootstrapFromCache(date) },
)
}
else -> syncRepository.bootstrap(date)
} }
val response = cached.value val response = cached.value
val settings = response.driverAppSettings val settings = response.driverAppSettings
@@ -639,6 +673,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 +1137,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
} }
@@ -0,0 +1,70 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class StartupOfflineFallbackLoaderTest {
@Test
fun returnsServerDataBeforeTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = { "server" },
offlineLoad = { "cache" },
)
assertEquals("server", result)
}
@Test
fun returnsCachedDataWhenServerExceedsTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { "cache" },
)
assertEquals("cache", result)
}
@Test
fun reportsMissingOfflineDataAfterTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val error = runCatching {
loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { null },
)
}.exceptionOrNull()
assertTrue(error is IOException)
}
@Test
fun doesNotHideAuthenticationFailures() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val authenticationFailure = IllegalStateException("401")
val error = runCatching {
loader.load(
onlineLoad = { throw authenticationFailure },
offlineLoad = { "cache" },
)
}.exceptionOrNull()
assertTrue(error is IllegalStateException)
assertEquals(authenticationFailure.message, error?.message)
}
}
@@ -0,0 +1,32 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class StartupSessionPolicyTest {
@Test
fun rotatesStartupMessagesAsWaitingTimeIncreases() {
assertEquals("Sprawdzamy zapisaną sesję", StartupSessionPolicy.message(0).title)
assertEquals("Potwierdzamy logowanie", StartupSessionPolicy.message(3).title)
assertEquals("Pobieramy aktualne kursy", StartupSessionPolicy.message(6).title)
assertEquals("To trwa dłużej niż zwykle", StartupSessionPolicy.message(9).title)
assertEquals("Przygotowujemy tryb offline", StartupSessionPolicy.message(12).title)
}
@Test
fun countsDownToOfflineFallbackWithoutGoingBelowZero() {
assertEquals(15, StartupSessionPolicy.remainingSeconds(0))
assertEquals(7, StartupSessionPolicy.remainingSeconds(8))
assertEquals(0, StartupSessionPolicy.remainingSeconds(15))
assertEquals(0, StartupSessionPolicy.remainingSeconds(30))
assertEquals("Tryb offline najpóźniej za 5 s", StartupSessionPolicy.countdownLabel(10))
assertEquals("Otwieramy zapisane dane offline", StartupSessionPolicy.countdownLabel(15))
}
@Test
fun showsManualOfflineButtonAfterFourSecondsOnlyWhenCacheExists() {
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(3, offlineDataAvailable = true))
assertEquals(true, StartupSessionPolicy.shouldShowOfflineButton(4, offlineDataAvailable = true))
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(10, offlineDataAvailable = false))
}
}
@@ -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(