Compare commits

...
11 changed files with 635 additions and 17 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 122 versionCode = 125
versionName = "1.0.69" versionName = "1.0.72"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse 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.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody 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.RouteResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.StartRouteBody
import pl.firmatpp.kierowca.data.model.SyncStateResponse 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.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -56,6 +58,12 @@ class DriverRepository(
suspend fun bootstrap(date: String? = null): BootstrapResponse = suspend fun bootstrap(date: String? = null): BootstrapResponse =
api.bootstrap(authHeader(requireToken()), date) 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 = suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId) api.route(authHeader(requireToken()), routeId)
@@ -3,6 +3,8 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse 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.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
@@ -67,6 +69,12 @@ interface MobileDriverApi {
@Query("date") date: String?, @Query("date") date: String?,
): BootstrapResponse ): BootstrapResponse
@POST("mobile/driver/tachograph-reminder/acknowledge")
suspend fun acknowledgeTachographReminder(
@Header("Authorization") authorization: String,
@Body body: AcknowledgeTachographReminderBody,
): AcknowledgeTachographReminderResponse
@GET("mobile/driver/leave-request-types") @GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes( suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -47,6 +47,7 @@ data class BootstrapResponse(
val routes: RoutesBucketDto, val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?, val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null, val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
@@ -80,6 +81,7 @@ data class DriverAppSettingsDto(
val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null,
val tachographRemindersEnabled: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null, val leaveRequests: LeaveRequestsConfigDto? = null,
) )
@@ -99,6 +101,30 @@ data class DispatchSheetReminderDto(
val canUpload: Boolean = false, 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( data class DispatchSheetPhotoDto(
val id: String, val id: String,
val driverId: String, val driverId: String,
@@ -5,6 +5,7 @@ import com.google.gson.Gson
import java.io.IOException import java.io.IOException
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository 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.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
@@ -38,6 +39,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState) saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedBootstrap(requestedDate, throwable) cachedBootstrap(requestedDate, throwable)
} }
} }
@@ -58,6 +60,7 @@ class DriverSyncRepository(
saveSyncStates(response.syncState) saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis()) CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
cachedRoute(routeId, throwable) 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.Phone
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SystemUpdate import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
@@ -83,7 +84,10 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -93,11 +97,13 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -145,6 +151,7 @@ import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.YearMonth import java.time.YearMonth
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -159,6 +166,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.NavigationPointDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto 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.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -309,7 +317,7 @@ fun DriverApp(
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) { BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
viewModel.back() viewModel.back()
} }
DisposableEffect(lifecycleOwner, state.screen) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_START -> { Lifecycle.Event.ON_START -> {
@@ -402,6 +410,7 @@ fun DriverApp(
state = state, state = state,
onRefresh = viewModel::refreshRoutesSilently, onRefresh = viewModel::refreshRoutesSilently,
onDate = viewModel::selectRouteDate, onDate = viewModel::selectRouteDate,
onTachographReminderComplete = viewModel::acknowledgeTachographReminder,
onProfile = viewModel::openProfile, onProfile = viewModel::openProfile,
onPhotoQueue = viewModel::openPhotoQueue, onPhotoQueue = viewModel::openPhotoQueue,
onDispatchSheetUpload = viewModel::uploadDispatchSheetPhoto, onDispatchSheetUpload = viewModel::uploadDispatchSheetPhoto,
@@ -1149,6 +1158,7 @@ private fun RoutesScreen(
state: DriverUiState, state: DriverUiState,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onDate: (String) -> Unit, onDate: (String) -> Unit,
onTachographReminderComplete: (List<String>) -> Unit,
onProfile: () -> Unit, onProfile: () -> Unit,
onPhotoQueue: () -> Unit, onPhotoQueue: () -> Unit,
onDispatchSheetUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onDispatchSheetUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
@@ -1159,6 +1169,8 @@ private fun RoutesScreen(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } 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 cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri val capturedUri = cameraUri
if ( if (
@@ -1239,7 +1251,10 @@ private fun RoutesScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TppTheme.colors.ink, color = TppTheme.colors.ink,
) )
DateChip(state.selectedDate) DateChip(
date = state.selectedDate,
onClick = { showRouteDatePicker = true },
)
} }
} else { } else {
Row( Row(
@@ -1253,7 +1268,10 @@ private fun RoutesScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TppTheme.colors.ink, color = TppTheme.colors.ink,
) )
DateChip(state.selectedDate) DateChip(
date = state.selectedDate,
onClick = { showRouteDatePicker = true },
)
} }
} }
} }
@@ -1265,6 +1283,15 @@ private fun RoutesScreen(
onDate = onDate, onDate = onDate,
) )
} }
if (shouldShowTachographReminderCard(state.tachographReminder)) {
item {
TachographReminderCard(
reminder = state.tachographReminder,
submitting = state.tachographReminderSubmitting,
onConfirm = { showTachographConfirmation = true },
)
}
}
if (shouldShowDispatchSheetReminderCard(state.dispatchSheetReminder)) { if (shouldShowDispatchSheetReminderCard(state.dispatchSheetReminder)) {
item { item {
DispatchSheetReminderCard( DispatchSheetReminderCard(
@@ -1333,6 +1360,31 @@ private fun RoutesScreen(
}, },
) )
} }
if (showRouteDatePicker) {
RouteDatePickerDialog(
selectedDate = state.selectedDate,
minDate = state.minRouteDate,
maxDate = state.maxRouteDate,
onDismiss = { showRouteDatePicker = false },
onDateSelected = { date ->
showRouteDatePicker = false
onDate(date)
},
)
}
if (showTachographConfirmation) {
TachographReminderConfirmationDialog(
reminder = state.tachographReminder,
submitting = state.tachographReminderSubmitting,
onDismiss = { showTachographConfirmation = false },
onConfirm = { taskIds ->
showTachographConfirmation = false
onTachographReminderComplete(taskIds)
},
)
}
} }
@Composable @Composable
@@ -2226,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 @Composable
private fun OperationalQueueBanner(state: DriverUiState, onPhotoQueue: () -> Unit) { private fun OperationalQueueBanner(state: DriverUiState, onPhotoQueue: () -> Unit) {
val count = state.pendingOperationalItems val count = state.pendingOperationalItems
@@ -2429,19 +2646,120 @@ private fun StitchHeader(height: Dp = 124.dp) {
} }
@Composable @Composable
private fun DateChip(date: String) { private fun DateChip(date: String, onClick: () -> Unit) {
val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) } val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) }
val label = remember(date) { LocalDate.parse(date).format(formatter).replace(".", "").replaceFirstChar { it.uppercase() } } val label = remember(date) { LocalDate.parse(date).format(formatter).replace(".", "").replaceFirstChar { it.uppercase() } }
Row( Row(
modifier = Modifier.background(TppTheme.colors.panel, RoundedCornerShape(12.dp)).padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier
.background(TppTheme.colors.panel, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) Icon(
Icons.Outlined.CalendarToday,
contentDescription = "Otwórz kalendarz",
tint = TppTheme.colors.forest,
)
Text(label, fontFamily = FontFamily.Monospace, color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold) Text(label, fontFamily = FontFamily.Monospace, color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold)
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RouteDatePickerDialog(
selectedDate: String,
minDate: String,
maxDate: String,
onDismiss: () -> Unit,
onDateSelected: (String) -> Unit,
) {
val bounds = remember(selectedDate, minDate, maxDate) {
routeDatePickerBounds(
selectedDate = selectedDate,
minDate = minDate,
maxDate = maxDate,
)
}
val selectableDates = remember(bounds) {
object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
val date = Instant.ofEpochMilli(utcTimeMillis).atZone(ZoneOffset.UTC).toLocalDate()
return date in bounds.minDate..bounds.maxDate
}
override fun isSelectableYear(year: Int): Boolean =
year in bounds.minDate.year..bounds.maxDate.year
}
}
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = bounds.selectedDate
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli(),
yearRange = bounds.minDate.year..bounds.maxDate.year,
selectableDates = selectableDates,
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
datePickerState.selectedDateMillis
?.let { Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate() }
?.takeIf { it in bounds.minDate..bounds.maxDate }
?.let { onDateSelected(it.toString()) }
},
enabled = datePickerState.selectedDateMillis != null,
) {
Text("Wybierz")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Anuluj")
}
},
) {
DatePicker(
state = datePickerState,
title = {
Text(
"Wybierz dzień kursów",
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp),
)
},
showModeToggle = false,
)
}
}
internal data class RouteDatePickerBounds(
val selectedDate: LocalDate,
val minDate: LocalDate,
val maxDate: LocalDate,
)
internal fun routeDatePickerBounds(
selectedDate: String,
minDate: String,
maxDate: String,
): RouteDatePickerBounds {
val selected = runCatching { LocalDate.parse(selectedDate) }.getOrDefault(LocalDate.now())
val parsedMin = runCatching { LocalDate.parse(minDate) }.getOrDefault(selected)
val parsedMax = runCatching { LocalDate.parse(maxDate) }.getOrDefault(selected)
val rangeMin = minOf(parsedMin, parsedMax)
val rangeMax = maxOf(parsedMin, parsedMax)
return RouteDatePickerBounds(
selectedDate = selected.coerceIn(rangeMin, rangeMax),
minDate = rangeMin,
maxDate = rangeMax,
)
}
@Composable @Composable
private fun RouteDateSelector(selectedDate: String, minDate: String, maxDate: String, onDate: (String) -> Unit) { private fun RouteDateSelector(selectedDate: String, minDate: String, maxDate: String, onDate: (String) -> Unit) {
val dates = remember(minDate, maxDate) { val dates = remember(minDate, maxDate) {
@@ -14,6 +14,8 @@ 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
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.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -671,6 +673,16 @@ fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLo
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean = fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded") 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 = fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when { when {
hasQueuedUpload -> "Wysyłam zdjęcie..." 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.ApiErrorKind
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata 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.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.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.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto 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.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
@@ -102,6 +104,8 @@ data class DriverUiState(
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val tachographReminder: TachographReminderDto? = null,
val tachographReminderSubmitting: Boolean = false,
val appUpdate: AppUpdateDto? = null, val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(), val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null, 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) { 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)
@@ -233,6 +261,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
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 private var routesLoadJob: Job? = null
private var startupLoadInProgress: Boolean = false
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
init { init {
@@ -470,6 +499,39 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
loadRoutes(date = date, showLoading = true, navigateToRoutes = true) 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() { fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) } _state.update { it.copy(routeDayLiveUpdateMessage = null) }
} }
@@ -480,24 +542,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
navigateToRoutes: Boolean, navigateToRoutes: Boolean,
startupOfflineOnly: Boolean = false, 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 val generation = ++routesRequestGeneration
routesLoadJob?.cancel() routesLoadJob?.cancel()
routesLoadJob = viewModelScope.launch { val loadJob = 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 = when { val cached = when {
startupOfflineOnly -> syncRepository.bootstrapFromCache(date) startupOfflineOnly -> 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 -> { isStartup -> {
startupOfflineFallbackLoader.load( startupOfflineFallbackLoader.load(
onlineLoad = { syncRepository.bootstrap(date) }, onlineLoad = { syncRepository.bootstrap(date) },
offlineLoad = { syncRepository.bootstrapFromCache(date) }, offlineLoad = { syncRepository.bootstrapFromCache(date) },
) )
} }
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
else -> syncRepository.bootstrap(date) else -> syncRepository.bootstrap(date)
} }
val response = cached.value val response = cached.value
@@ -509,6 +582,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver, driver = response.session.driver,
routes = response.routes.today, routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder, dispatchSheetReminder = response.dispatchSheetReminder,
tachographReminder = response.tachographReminder,
appUpdate = response.appUpdate ?: it.appUpdate, appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate, selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate, minRouteDate = settings?.minDate ?: it.minRouteDate,
@@ -526,10 +600,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
leaveRequestsConfig = settings?.leaveRequests?.let { config -> leaveRequestsConfig = settings?.leaveRequests?.let { config ->
LeaveRequestsConfig(enabled = config.enabled, types = config.types) LeaveRequestsConfig(enabled = config.enabled, types = config.types)
}, },
screen = if ( screen = screenAfterBootstrap(
settings?.leaveRequests?.enabled != true && currentScreen = it.screen,
it.screen in setOf(DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail, DriverScreen.AddLeaveRequest) navigateToRoutes = navigateToRoutes,
) DriverScreen.Routes else if (navigateToRoutes) DriverScreen.Routes else it.screen, leaveRequestsEnabled = settings?.leaveRequests?.enabled == true,
),
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes, notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(), imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
@@ -556,6 +631,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
reconcileTrackingService(response.routes.today, response.session.driver) reconcileTrackingService(response.routes.today, response.session.driver)
} }
}.onFailure { throwable -> }.onFailure { throwable ->
throwable.rethrowIfCancellation()
if (generation != routesRequestGeneration) return@onFailure if (generation != routesRequestGeneration) return@onFailure
reportHandledException("load_routes", throwable, mapOf("date" to date)) reportHandledException("load_routes", throwable, mapOf("date" to date))
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) { if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
@@ -573,6 +649,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy(loading = false, refreshing = false) 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) fun openRoute(routeId: String) = openRouteWithTarget(routeId, DriverScreen.Detail)
@@ -1773,7 +1857,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private suspend fun checkRemoteSyncState() { 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 { syncCheckMutex.withLock {
syncHintMutex.withLock { highestHintVersions.clear() } syncHintMutex.withLock { highestHintVersions.clear() }
val snapshot = _state.value val snapshot = _state.value
@@ -1820,6 +1909,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private suspend fun handleSyncHint(hint: DriverSyncHint) { private suspend fun handleSyncHint(hint: DriverSyncHint) {
val initialSnapshot = _state.value
if (initialSnapshot.screen == DriverScreen.Initializing || initialSnapshot.driver == null) return
syncHintMutex.withLock { syncHintMutex.withLock {
val hintKey = listOf(hint.scope, hint.date ?: "-", hint.routeId ?: "-").joinToString(":") val hintKey = listOf(hint.scope, hint.date ?: "-", hint.routeId ?: "-").joinToString(":")
val previousVersion = highestHintVersions[hintKey] 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.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
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.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
@@ -410,6 +412,57 @@ class DriverUiRulesTest {
assertFalse(shouldShowDispatchSheetReminderCard(null)) 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 @Test
fun explainsOfflineStateWithoutCachedSync() { fun explainsOfflineStateWithoutCachedSync() {
assertEquals( assertEquals(
@@ -817,4 +870,11 @@ class DriverUiRulesTest {
photo = null, photo = null,
canUpload = dueToday, 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) 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 @Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() { fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState( val state = DriverUiState(
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteDatePickerBoundsTest {
@Test
fun usesTheSameMinAndMaxDatesAsTheRouteSelector() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-24"), bounds.selectedDate)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
@Test
fun clampsSelectedDateToAllowedRange() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-30",
minDate = "2026-07-17",
maxDate = "2026-07-26",
)
assertEquals(LocalDate.parse("2026-07-26"), bounds.selectedDate)
}
@Test
fun normalizesAccidentallyReversedServerBounds() {
val bounds = routeDatePickerBounds(
selectedDate = "2026-07-24",
minDate = "2026-07-26",
maxDate = "2026-07-17",
)
assertEquals(LocalDate.parse("2026-07-17"), bounds.minDate)
assertEquals(LocalDate.parse("2026-07-26"), bounds.maxDate)
}
}