Compare commits

..
10 changed files with 461 additions and 12 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 36
versionCode = 123
versionName = "1.0.70"
versionCode = 125
versionName = "1.0.72"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -12,6 +12,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -33,6 +34,7 @@ import pl.firmatpp.kierowca.data.model.RoutePointsResponse
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -56,6 +58,12 @@ class DriverRepository(
suspend fun bootstrap(date: String? = null): BootstrapResponse =
api.bootstrap(authHeader(requireToken()), date)
suspend fun acknowledgeTachographReminder(taskIds: List<String>): TachographReminderDto =
api.acknowledgeTachographReminder(
authHeader(requireToken()),
AcknowledgeTachographReminderBody(taskIds),
).tachographReminder
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody
import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderBody
import pl.firmatpp.kierowca.data.model.AcknowledgeTachographReminderResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -67,6 +69,12 @@ interface MobileDriverApi {
@Query("date") date: String?,
): BootstrapResponse
@POST("mobile/driver/tachograph-reminder/acknowledge")
suspend fun acknowledgeTachographReminder(
@Header("Authorization") authorization: String,
@Body body: AcknowledgeTachographReminderBody,
): AcknowledgeTachographReminderResponse
@GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String,
@@ -47,6 +47,7 @@ data class BootstrapResponse(
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
@@ -80,6 +81,7 @@ data class DriverAppSettingsDto(
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
val tachographRemindersEnabled: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null,
)
@@ -99,6 +101,30 @@ data class DispatchSheetReminderDto(
val canUpload: Boolean = false,
)
data class TachographReminderDto(
val enabled: Boolean = false,
val visible: Boolean = false,
val title: String? = null,
val message: String? = null,
val confirmationLabel: String? = null,
val tasks: List<TachographReminderTaskDto>? = null,
)
data class TachographReminderTaskDto(
val id: String,
val type: String,
val label: String,
)
data class AcknowledgeTachographReminderBody(
val taskIds: List<String>,
)
data class AcknowledgeTachographReminderResponse(
val ok: Boolean,
val tachographReminder: TachographReminderDto,
)
data class DispatchSheetPhotoDto(
val id: String,
val driverId: String,
@@ -5,6 +5,7 @@ import com.google.gson.Gson
import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse
@@ -38,6 +39,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedBootstrap(requestedDate, throwable)
}
}
@@ -58,6 +60,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedRoute(routeId, throwable)
}
@@ -74,6 +74,7 @@ import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
@@ -83,6 +84,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
@@ -164,6 +166,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -314,7 +317,7 @@ fun DriverApp(
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
viewModel.back()
}
DisposableEffect(lifecycleOwner, state.screen) {
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
@@ -407,6 +410,7 @@ fun DriverApp(
state = state,
onRefresh = viewModel::refreshRoutesSilently,
onDate = viewModel::selectRouteDate,
onTachographReminderComplete = viewModel::acknowledgeTachographReminder,
onProfile = viewModel::openProfile,
onPhotoQueue = viewModel::openPhotoQueue,
onDispatchSheetUpload = viewModel::uploadDispatchSheetPhoto,
@@ -1154,6 +1158,7 @@ private fun RoutesScreen(
state: DriverUiState,
onRefresh: () -> Unit,
onDate: (String) -> Unit,
onTachographReminderComplete: (List<String>) -> Unit,
onProfile: () -> Unit,
onPhotoQueue: () -> Unit,
onDispatchSheetUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
@@ -1165,6 +1170,7 @@ private fun RoutesScreen(
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var showRouteDatePicker by remember { mutableStateOf(false) }
var showTachographConfirmation by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri
if (
@@ -1277,6 +1283,15 @@ private fun RoutesScreen(
onDate = onDate,
)
}
if (shouldShowTachographReminderCard(state.tachographReminder)) {
item {
TachographReminderCard(
reminder = state.tachographReminder,
submitting = state.tachographReminderSubmitting,
onConfirm = { showTachographConfirmation = true },
)
}
}
if (shouldShowDispatchSheetReminderCard(state.dispatchSheetReminder)) {
item {
DispatchSheetReminderCard(
@@ -1358,6 +1373,18 @@ private fun RoutesScreen(
},
)
}
if (showTachographConfirmation) {
TachographReminderConfirmationDialog(
reminder = state.tachographReminder,
submitting = state.tachographReminderSubmitting,
onDismiss = { showTachographConfirmation = false },
onConfirm = { taskIds ->
showTachographConfirmation = false
onTachographReminderComplete(taskIds)
},
)
}
}
@Composable
@@ -2251,6 +2278,171 @@ private fun DispatchSheetReminderCard(
}
}
@Composable
private fun TachographReminderCard(
reminder: TachographReminderDto?,
submitting: Boolean,
onConfirm: () -> Unit,
) {
if (!shouldShowTachographReminderCard(reminder)) return
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer),
border = BorderStroke(1.dp, TppTheme.colors.warningOutline),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top,
) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.72f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.Speed,
contentDescription = null,
tint = Color(0xFF7A5200),
modifier = Modifier.size(27.dp),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(
reminder?.title.orEmpty(),
color = TppTheme.colors.ink,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
reminder?.message.orEmpty(),
color = Color(0xFF5F4200),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Button(
onClick = onConfirm,
enabled = !submitting,
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, bottom = 18.dp)
.height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
) {
if (submitting) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = Color.White,
)
}
Spacer(Modifier.width(10.dp))
Text(
reminder?.confirmationLabel?.takeIf { it.isNotBlank() } ?: "Wykonano",
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun TachographReminderConfirmationDialog(
reminder: TachographReminderDto?,
submitting: Boolean,
onDismiss: () -> Unit,
onConfirm: (List<String>) -> Unit,
) {
val tasks = reminder?.tasks.orEmpty()
val taskIds = tasks.map { it.id }
var selectedIds by remember(taskIds) {
mutableStateOf(defaultTachographConfirmationSelection(tasks))
}
if (tasks.isEmpty()) {
LaunchedEffect(Unit) { onDismiss() }
return
}
AlertDialog(
onDismissRequest = { if (!submitting) onDismiss() },
title = {
Text(
"Potwierdź wykonanie",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
if (tasks.size == 1) {
"Potwierdź wykonanie poniższej czynności."
} else {
"Zaznacz, które czynności zostały wykonane."
},
color = TppTheme.colors.muted,
)
tasks.forEach { task ->
val checked = task.id in selectedIds
Row(
modifier = Modifier
.fillMaxWidth()
.background(TppTheme.colors.panel, RoundedCornerShape(10.dp))
.clickable(enabled = !submitting) {
selectedIds = if (checked) selectedIds - task.id else selectedIds + task.id
}
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = checked,
onCheckedChange = { isChecked ->
selectedIds = if (isChecked) selectedIds + task.id else selectedIds - task.id
},
enabled = !submitting,
)
Text(
task.label,
modifier = Modifier.weight(1f),
color = TppTheme.colors.ink,
fontWeight = FontWeight.SemiBold,
)
}
}
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(taskIds.filter { it in selectedIds }) },
enabled = selectedIds.isNotEmpty() && !submitting,
) {
Text("Potwierdź")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !submitting,
) {
Text("Anuluj")
}
},
)
}
@Composable
private fun OperationalQueueBanner(state: DriverUiState, onPhotoQueue: () -> Unit) {
val count = state.pendingOperationalItems
@@ -14,6 +14,8 @@ import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -671,6 +673,16 @@ fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLo
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun shouldShowTachographReminderCard(reminder: TachographReminderDto?): Boolean =
reminder?.enabled == true &&
reminder.visible &&
!reminder.title.isNullOrBlank() &&
!reminder.message.isNullOrBlank() &&
!reminder.tasks.isNullOrEmpty()
fun defaultTachographConfirmationSelection(tasks: List<TachographReminderTaskDto>): Set<String> =
if (tasks.size == 1) setOf(tasks.single().id) else emptySet()
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when {
hasQueuedUpload -> "Wysyłam zdjęcie..."
@@ -23,6 +23,7 @@ import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader
@@ -33,6 +34,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
@@ -102,6 +104,8 @@ data class DriverUiState(
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val tachographReminderSubmitting: Boolean = false,
val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
@@ -199,6 +203,30 @@ internal fun DriverUiState.withLoadedLeaveRequests(
)
}
internal fun shouldStartRoutesLoad(
screen: DriverScreen,
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean,
startupLoadInProgress: Boolean = false,
): Boolean =
(!startupLoadInProgress || navigateToRoutes || startupOfflineOnly) &&
(screen != DriverScreen.Initializing || navigateToRoutes || startupOfflineOnly)
internal fun screenAfterBootstrap(
currentScreen: DriverScreen,
navigateToRoutes: Boolean,
leaveRequestsEnabled: Boolean,
): DriverScreen = when {
currentScreen == DriverScreen.Initializing -> DriverScreen.Routes
!leaveRequestsEnabled && currentScreen in setOf(
DriverScreen.LeaveRequests,
DriverScreen.LeaveRequestDetail,
DriverScreen.AddLeaveRequest,
) -> DriverScreen.Routes
navigateToRoutes -> DriverScreen.Routes
else -> currentScreen
}
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application)
@@ -233,6 +261,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var routesRequestGeneration: Long = 0
private var routeDetailRequestGeneration: Long = 0
private var routesLoadJob: Job? = null
private var startupLoadInProgress: Boolean = false
val state: StateFlow<DriverUiState> = _state
init {
@@ -470,6 +499,39 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun acknowledgeTachographReminder(taskIds: List<String>) {
val snapshot = _state.value
if (taskIds.isEmpty() || snapshot.tachographReminderSubmitting) return
if (!snapshot.isOnline) {
_state.update { it.copy(error = "Potwierdzenie odczytu wymaga połączenia z internetem.") }
return
}
viewModelScope.launch {
_state.update {
it.copy(
tachographReminderSubmitting = true,
error = null,
feedback = null,
)
}
runCatching { repository.acknowledgeTachographReminder(taskIds) }
.onSuccess { reminder ->
_state.update {
it.copy(
tachographReminder = reminder,
feedback = "Zapisano potwierdzenie odczytu.",
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(tachographReminderSubmitting = false) }
}
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
@@ -480,24 +542,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean = false,
) {
val requestSnapshot = _state.value
if (!shouldStartRoutesLoad(
screen = requestSnapshot.screen,
navigateToRoutes = navigateToRoutes,
startupOfflineOnly = startupOfflineOnly,
startupLoadInProgress = startupLoadInProgress,
)
) {
return
}
val isStartup = requestSnapshot.screen == DriverScreen.Initializing
if (isStartup) startupLoadInProgress = true
val generation = ++routesRequestGeneration
routesLoadJob?.cancel()
routesLoadJob = viewModelScope.launch {
val isStartup = _state.value.screen == DriverScreen.Initializing
val loadJob = viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching {
val cached = when {
startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
?: 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) },
)
}
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
else -> syncRepository.bootstrap(date)
}
val response = cached.value
@@ -509,6 +582,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver,
routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
tachographReminder = response.tachographReminder,
appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate,
@@ -526,10 +600,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
leaveRequestsConfig = settings?.leaveRequests?.let { config ->
LeaveRequestsConfig(enabled = config.enabled, types = config.types)
},
screen = if (
settings?.leaveRequests?.enabled != true &&
it.screen in setOf(DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail, DriverScreen.AddLeaveRequest)
) DriverScreen.Routes else if (navigateToRoutes) DriverScreen.Routes else it.screen,
screen = screenAfterBootstrap(
currentScreen = it.screen,
navigateToRoutes = navigateToRoutes,
leaveRequestsEnabled = settings?.leaveRequests?.enabled == true,
),
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
@@ -556,6 +631,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
reconcileTrackingService(response.routes.today, response.session.driver)
}
}.onFailure { throwable ->
throwable.rethrowIfCancellation()
if (generation != routesRequestGeneration) return@onFailure
reportHandledException("load_routes", throwable, mapOf("date" to date))
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
@@ -573,6 +649,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy(loading = false, refreshing = false)
}
}
routesLoadJob = loadJob
if (isStartup) {
loadJob.invokeOnCompletion {
if (generation == routesRequestGeneration) {
startupLoadInProgress = false
}
}
}
}
fun openRoute(routeId: String) = openRouteWithTarget(routeId, DriverScreen.Detail)
@@ -1773,7 +1857,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private suspend fun checkRemoteSyncState() {
if (!_state.value.isOnline) return
val initialSnapshot = _state.value
if (
!initialSnapshot.isOnline ||
initialSnapshot.screen == DriverScreen.Initializing ||
initialSnapshot.driver == null
) return
syncCheckMutex.withLock {
syncHintMutex.withLock { highestHintVersions.clear() }
val snapshot = _state.value
@@ -1820,6 +1909,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
private suspend fun handleSyncHint(hint: DriverSyncHint) {
val initialSnapshot = _state.value
if (initialSnapshot.screen == DriverScreen.Initializing || initialSnapshot.driver == null) return
syncHintMutex.withLock {
val hintKey = listOf(hint.scope, hint.date ?: "-", hint.routeId ?: "-").joinToString(":")
val previousVersion = highestHintVersions[hintKey]
@@ -8,6 +8,8 @@ import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.TachographReminderDto
import pl.firmatpp.kierowca.data.model.TachographReminderTaskDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus
@@ -410,6 +412,57 @@ class DriverUiRulesTest {
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun showsTachographCardOnlyForCompleteReminderEnabledByBackend() {
assertTrue(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = false,
visible = true,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(
shouldShowTachographReminderCard(
TachographReminderDto(
enabled = true,
visible = false,
title = "Odczyt tachografu",
message = "Pamiętaj o odczycie.",
tasks = listOf(tachographTask()),
),
),
)
assertFalse(shouldShowTachographReminderCard(null))
}
@Test
fun preselectsOnlySingleTachographConfirmationTask() {
val driverCard = tachographTask()
val vehicle = TachographReminderTaskDto(
id = "vehicle:12:2026-07-31",
type = "vehicle_tachograph",
label = "Tachograf pojazdu",
)
assertEquals(setOf(driverCard.id), defaultTachographConfirmationSelection(listOf(driverCard)))
assertEquals(emptySet<String>(), defaultTachographConfirmationSelection(listOf(driverCard, vehicle)))
}
@Test
fun explainsOfflineStateWithoutCachedSync() {
assertEquals(
@@ -817,4 +870,11 @@ class DriverUiRulesTest {
photo = null,
canUpload = dueToday,
)
private fun tachographTask(): TachographReminderTaskDto =
TachographReminderTaskDto(
id = "driver-card:1:2026-07-31",
type = "driver_card",
label = "Karta kierowcy",
)
}
@@ -43,6 +43,55 @@ class DriverUiStateTest {
assertEquals(ServerConnectionState.Degraded, failed.serverConnectionState)
}
@Test
fun silentRefreshCannotReplaceActiveStartupLoad() {
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = false,
),
)
assertFalse(
shouldStartRoutesLoad(
screen = DriverScreen.Routes,
navigateToRoutes = false,
startupOfflineOnly = false,
startupLoadInProgress = true,
),
)
}
@Test
fun startupAndManualOfflineLoadsRemainAvailable() {
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = true,
startupOfflineOnly = false,
),
)
assertTrue(
shouldStartRoutesLoad(
screen = DriverScreen.Initializing,
navigateToRoutes = false,
startupOfflineOnly = true,
),
)
}
@Test
fun successfulBootstrapAlwaysLeavesInitializingScreen() {
assertEquals(
DriverScreen.Routes,
screenAfterBootstrap(
currentScreen = DriverScreen.Initializing,
navigateToRoutes = false,
leaveRequestsEnabled = true,
),
)
}
@Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState(