Compare commits

..
Author SHA1 Message Date
admin fda4b462dc Wydaj aplikację kierowcy 1.0.70 (123) 2026-07-24 12:55:12 +02:00
admin 6a1a15ade9 Add route date calendar picker 2026-07-24 12:49:32 +02:00
3 changed files with 176 additions and 7 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 = 123
versionName = "1.0.69" versionName = "1.0.70"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -84,6 +84,8 @@ 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.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 +95,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 +149,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
@@ -1159,6 +1164,7 @@ 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) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri val capturedUri = cameraUri
if ( if (
@@ -1239,7 +1245,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 +1262,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 },
)
} }
} }
} }
@@ -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 @Composable
@@ -2429,19 +2454,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) {
@@ -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)
}
}