From 6a1a15ade98a8fc83334af736878f425dd13ca27 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 24 Jul 2026 12:49:32 +0200 Subject: [PATCH] Add route date calendar picker --- .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 136 +++++++++++++++++- .../kierowca/ui/RouteDatePickerBoundsTest.kt | 43 ++++++ 2 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/pl/firmatpp/kierowca/ui/RouteDatePickerBoundsTest.kt diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index cb98e98..5086321 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -84,6 +84,8 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -93,11 +95,13 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SelectableDates import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -145,6 +149,7 @@ import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth import java.time.ZoneId +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -1159,6 +1164,7 @@ private fun RoutesScreen( val coroutineScope = rememberCoroutineScope() var cameraUri by remember { mutableStateOf(null) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } + var showRouteDatePicker by remember { mutableStateOf(false) } val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val capturedUri = cameraUri if ( @@ -1239,7 +1245,10 @@ private fun RoutesScreen( fontWeight = FontWeight.Bold, color = TppTheme.colors.ink, ) - DateChip(state.selectedDate) + DateChip( + date = state.selectedDate, + onClick = { showRouteDatePicker = true }, + ) } } else { Row( @@ -1253,7 +1262,10 @@ private fun RoutesScreen( fontWeight = FontWeight.Bold, color = TppTheme.colors.ink, ) - DateChip(state.selectedDate) + DateChip( + date = state.selectedDate, + onClick = { showRouteDatePicker = true }, + ) } } } @@ -1333,6 +1345,19 @@ private fun RoutesScreen( }, ) } + + if (showRouteDatePicker) { + RouteDatePickerDialog( + selectedDate = state.selectedDate, + minDate = state.minRouteDate, + maxDate = state.maxRouteDate, + onDismiss = { showRouteDatePicker = false }, + onDateSelected = { date -> + showRouteDatePicker = false + onDate(date) + }, + ) + } } @Composable @@ -2429,19 +2454,120 @@ private fun StitchHeader(height: Dp = 124.dp) { } @Composable -private fun DateChip(date: String) { +private fun DateChip(date: String, onClick: () -> Unit) { val formatter = remember { DateTimeFormatter.ofPattern("d MMM", Locale("pl", "PL")) } val label = remember(date) { LocalDate.parse(date).format(formatter).replace(".", "").replaceFirstChar { it.uppercase() } } 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, 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) } } +@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 private fun RouteDateSelector(selectedDate: String, minDate: String, maxDate: String, onDate: (String) -> Unit) { val dates = remember(minDate, maxDate) { diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/RouteDatePickerBoundsTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/RouteDatePickerBoundsTest.kt new file mode 100644 index 0000000..3b54d0c --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/RouteDatePickerBoundsTest.kt @@ -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) + } +}