Add route date calendar picker
This commit is contained in:
@@ -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<Uri?>(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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user