Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905711d8de | ||
|
|
7c9cb3fb0d | ||
|
|
ae0d1b2d3d | ||
|
|
c5b3c5dae9 | ||
|
|
3fa3b4eac1 | ||
|
|
197bcc6f5e | ||
|
|
f4f5cec2be | ||
|
|
5a630f8401 |
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user