Rozdziel załadunek i dodaj kalendarz urlopów

This commit is contained in:
admin
2026-07-09 00:05:57 +02:00
parent 32d45f6430
commit c62cfecd74
11 changed files with 850 additions and 166 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 51
versionName = "1.0.45"
versionCode = 101
versionName = "1.0.48"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -18,6 +18,7 @@ import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
@@ -61,6 +62,9 @@ class DriverRepository(
suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
api.leaveCalendar(authHeader(requireToken()), from, to).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
@@ -10,6 +10,7 @@ import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
@@ -76,6 +77,13 @@ interface MobileDriverApi {
@Header("Authorization") authorization: String,
): LeaveRequestListResponse
@GET("mobile/driver/leave-requests/calendar")
suspend fun leaveCalendar(
@Header("Authorization") authorization: String,
@Query("from") from: String,
@Query("to") to: String,
): LeaveCalendarResponse
@POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest(
@Header("Authorization") authorization: String,
@@ -167,6 +167,10 @@ data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(),
)
data class LeaveCalendarResponse(
val data: List<DriverLeaveCalendarEntryDto> = emptyList(),
)
data class LeaveRequestResponse(
val data: DriverLeaveRequestDto,
)
@@ -206,6 +210,22 @@ data class DriverLeaveRequestDto(
val events: List<DriverLeaveRequestEventDto> = emptyList(),
)
data class DriverLeaveCalendarEntryDto(
val id: String,
val dateFrom: String?,
val dateTo: String?,
val type: String,
val typeLabel: String? = null,
val status: String,
val driver: DriverLeaveCalendarDriverDto,
)
data class DriverLeaveCalendarDriverDto(
val id: String,
val displayName: String?,
val phoneNumber: String?,
)
data class DriverLeaveRequestEventDto(
val id: String,
val action: String,
@@ -77,13 +77,12 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
@@ -91,7 +90,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDateRangePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -99,6 +98,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -131,15 +131,18 @@ import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import java.io.File
import java.time.Instant
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
import java.util.Locale
import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
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.NavigationPointDto
@@ -259,16 +262,35 @@ fun DriverApp(
)
DriverScreen.StartRoute -> RouteStageScreen(
state = state,
title = "Rozpocznij kurs",
title = "Zdjęcia załadunku",
stage = "loading",
weightLabel = "Waga na załadunku",
submitLabel = "Rozpocznij kurs",
submitLabel = "Gotowe, jadę na wagę",
showWeightInput = false,
showPhotoActions = true,
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::openLoadingWeight,
)
DriverScreen.LoadingWeight -> RouteStageScreen(
state = state,
title = "Waga załadunku",
stage = "loading",
weightLabel = "Waga z wagi",
submitLabel = "Rozpocznij kurs",
showWeightInput = true,
showPhotoActions = false,
showPhotoGrid = false,
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { _, _, _ -> },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitStartRoute,
)
DriverScreen.FinishRoute -> RouteStageScreen(
@@ -303,10 +325,18 @@ fun DriverApp(
onBack = viewModel::back,
onCancel = viewModel::cancelSelectedLeaveRequest,
)
DriverScreen.LeaveCalendar -> LeaveCalendarScreen(
state = state,
onBack = viewModel::back,
onDateSelected = viewModel::selectLeaveCalendarDate,
onContinue = viewModel::continueAddLeaveRequestFromCalendar,
onLoadMore = { viewModel.loadMoreLeaveCalendarMonths(reset = false) },
)
DriverScreen.AddLeaveRequest -> AddLeaveRequestScreen(
state = state,
onBack = viewModel::back,
onDraft = viewModel::updateLeaveRequestDraft,
onChangePeriod = viewModel::reopenLeaveCalendarFromDraft,
onSubmit = viewModel::submitLeaveRequest,
)
}
@@ -1011,25 +1041,344 @@ private fun LeaveRequestDetailScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LeaveCalendarScreen(
state: DriverUiState,
onBack: () -> Unit,
onDateSelected: (String) -> Unit,
onContinue: () -> Unit,
onLoadMore: () -> Unit,
) {
val context = LocalContext.current
val listState = rememberLazyListState()
val months = remember(state.leaveCalendarLoadedUntil) { leaveCalendarMonths(state.leaveCalendarLoadedUntil) }
var detailsDate by remember { mutableStateOf<LocalDate?>(null) }
val detailsEntries = detailsDate
?.let { DriverLeaveRequestUiRules.calendarEntriesForDate(state.leaveCalendarEntries, it) }
.orEmpty()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
LaunchedEffect(months.size, state.leaveCalendarLoading) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
.distinctUntilChanged()
.collect { lastVisibleIndex ->
if (state.leaveCalendarLoadedUntil != null && !state.leaveCalendarLoading && lastVisibleIndex >= months.size) {
onLoadMore()
}
}
}
Scaffold(
topBar = { SimpleTopBar("Kalendarz urlopów", onBack) },
bottomBar = {
LeaveCalendarContinueBar(
state = state,
onContinue = onContinue,
)
},
containerColor = TppTheme.colors.surface,
) { padding ->
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { FeedbackAndError(null, state.error) }
items(months, key = { it.toString() }) { month ->
LeaveCalendarMonth(
month = month,
entries = state.leaveCalendarEntries,
selectedFrom = state.leaveRequestDateFrom,
selectedTo = state.leaveRequestDateTo,
hasSelection = state.leaveCalendarHasSelection,
onDayClick = { date, entries ->
onDateSelected(date.toString())
if (entries.isNotEmpty()) {
detailsDate = date
}
},
)
}
item {
if (state.leaveCalendarLoading) {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(color = TppTheme.colors.forest, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(10.dp))
Text("Pobieram kolejne miesiące", color = TppTheme.colors.muted)
}
} else {
TextButton(onClick = onLoadMore, modifier = Modifier.fillMaxWidth()) {
Text("Pokaż kolejne miesiące", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold)
}
}
}
}
}
if (detailsDate != null && detailsEntries.isNotEmpty()) {
ModalBottomSheet(
onDismissRequest = { detailsDate = null },
sheetState = sheetState,
containerColor = TppTheme.colors.card,
) {
LeaveCalendarDetailsSheet(
date = detailsDate,
entries = detailsEntries,
onCall = { dialUri ->
context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse(dialUri)))
},
)
}
}
}
@Composable
private fun LeaveCalendarContinueBar(state: DriverUiState, onContinue: () -> Unit) {
val selectionText = when {
!state.leaveCalendarHasSelection -> "Wybierz pierwszy dzień urlopu"
else -> leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo)
}
Column(
Modifier
.fillMaxWidth()
.background(Color.White)
.border(0.5.dp, TppTheme.colors.outline.copy(alpha = 0.65f))
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Wybrany termin", color = TppTheme.colors.muted, fontSize = 12.sp, fontWeight = FontWeight.Bold)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
selectionText,
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Button(
onClick = onContinue,
enabled = state.leaveCalendarHasSelection,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.height(48.dp),
) {
Text("Dalej", fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun LeaveCalendarMonth(
month: YearMonth,
entries: List<DriverLeaveCalendarEntryDto>,
selectedFrom: String,
selectedTo: String,
hasSelection: Boolean,
onDayClick: (LocalDate, List<DriverLeaveCalendarEntryDto>) -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
leaveCalendarMonthTitle(month),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
leaveCalendarWeekdays().forEach { day ->
Text(
leaveCalendarWeekdayLabel(day),
color = TppTheme.colors.muted,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
monthDaysGrid(month).chunked(7).forEach { week ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
week.forEach { date ->
val dayEntries = date
?.let { DriverLeaveRequestUiRules.calendarEntriesForDate(entries, it) }
.orEmpty()
LeaveCalendarDayCell(
date = date,
entries = dayEntries,
selected = date?.let { leaveCalendarDateInRange(it, selectedFrom, selectedTo, hasSelection) } == true,
today = date == LocalDate.now(),
enabled = date != null && !date.isBefore(LocalDate.now()),
onClick = {
if (date != null) {
onDayClick(date, dayEntries)
}
},
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
@Composable
private fun LeaveCalendarDayCell(
date: LocalDate?,
entries: List<DriverLeaveCalendarEntryDto>,
selected: Boolean,
today: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasConflicts = entries.isNotEmpty()
val background = when {
selected -> TppTheme.colors.successContainer
hasConflicts -> TppTheme.colors.warningContainer
today -> Color.White
else -> TppTheme.colors.panel
}
val outline = when {
selected -> TppTheme.colors.forest
hasConflicts -> TppTheme.colors.warningOutline
today -> TppTheme.colors.forest.copy(alpha = 0.45f)
else -> TppTheme.colors.outline.copy(alpha = 0.45f)
}
val textColor = when {
!enabled -> TppTheme.colors.muted.copy(alpha = 0.45f)
selected -> TppTheme.colors.forest
hasConflicts -> Color(0xFF7A5A00)
else -> TppTheme.colors.ink
}
Box(
modifier
.aspectRatio(1f)
.background(background, RoundedCornerShape(4.dp))
.border(1.dp, outline, RoundedCornerShape(4.dp))
.clickable(enabled = enabled, onClick = onClick)
.padding(4.dp),
) {
if (date != null) {
Text(
date.dayOfMonth.toString(),
color = textColor,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.align(Alignment.TopStart),
)
if (hasConflicts) {
Text(
entries.size.toString(),
color = if (selected) Color.White else Color(0xFF7A5A00),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier
.align(Alignment.BottomEnd)
.background(if (selected) TppTheme.colors.forest else Color(0xFFFFE08A), RoundedCornerShape(999.dp))
.padding(horizontal = 5.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun LeaveCalendarDetailsSheet(
date: LocalDate?,
entries: List<DriverLeaveCalendarEntryDto>,
onCall: (String) -> Unit,
) {
Column(
Modifier.fillMaxWidth().navigationBarsPadding().padding(start = 20.dp, end = 20.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
date?.let { DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("pl", "PL")).format(it) } ?: "Urlopy w tym dniu",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
entries.forEach { entry ->
LeaveCalendarDetailsEntry(entry = entry, onCall = onCall)
}
}
}
@Composable
private fun LeaveCalendarDetailsEntry(entry: DriverLeaveCalendarEntryDto, onCall: (String) -> Unit) {
val dialUri = DriverLeaveRequestUiRules.phoneDialUri(entry.driver.phoneNumber)
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppTheme.colors.outline),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
entry.driver.displayName?.takeIf { it.isNotBlank() } ?: "Kierowca #${entry.driver.id}",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
)
Text(
leaveDateRange(entry.dateFrom, entry.dateTo),
color = TppTheme.colors.muted,
fontWeight = FontWeight.SemiBold,
)
}
LeaveStatusPill(entry.status)
}
DetailLine("Typ", entry.typeLabel?.takeIf { it.isNotBlank() } ?: DriverLeaveRequestUiRules.typeLabel(entry.type))
DetailLine("Telefon", entry.driver.phoneNumber?.takeIf { it.isNotBlank() } ?: "Brak numeru telefonu")
if (dialUri != null) {
Button(
onClick = { onCall(dialUri) },
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().height(50.dp),
) {
Icon(Icons.Outlined.Phone, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Dzwoń", fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun AddLeaveRequestScreen(
state: DriverUiState,
onBack: () -> Unit,
onDraft: (String?, String?, String?, String?) -> Unit,
onChangePeriod: () -> Unit,
onSubmit: () -> Unit,
) {
var showDateRangePicker by remember { mutableStateOf(false) }
val selectedStartMillis = remember(state.leaveRequestDateFrom) {
localDateStringToUtcMillis(state.leaveRequestDateFrom)
}
val selectedEndMillis = remember(state.leaveRequestDateTo) {
localDateStringToUtcMillis(state.leaveRequestDateTo)
}
val dateRangePickerState = rememberDateRangePickerState(
initialSelectedStartDateMillis = selectedStartMillis,
initialSelectedEndDateMillis = selectedEndMillis,
)
Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppTheme.colors.surface) { padding ->
LazyColumn(
Modifier.fillMaxSize().padding(padding),
@@ -1063,7 +1412,7 @@ private fun AddLeaveRequestScreen(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().clickable { showDateRangePicker = true },
modifier = Modifier.fillMaxWidth().clickable { onChangePeriod() },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
@@ -1107,55 +1456,6 @@ private fun AddLeaveRequestScreen(
}
}
}
if (showDateRangePicker) {
DatePickerDialog(
onDismissRequest = { showDateRangePicker = false },
confirmButton = {
TextButton(
onClick = {
val start = dateRangePickerState.selectedStartDateMillis
val end = dateRangePickerState.selectedEndDateMillis ?: start
if (start != null && end != null) {
onDraft(utcMillisToLocalDateString(start), utcMillisToLocalDateString(end), null, null)
}
showDateRangePicker = false
},
enabled = dateRangePickerState.selectedStartDateMillis != null,
) {
Text("Ustaw", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDateRangePicker = false }) {
Text("Anuluj", color = TppTheme.colors.muted)
}
},
) {
DateRangePicker(
state = dateRangePickerState,
title = {
Text(
"Wybierz okres urlopu",
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
headline = {
Text(
leaveDateRange(
dateRangePickerState.selectedStartDateMillis?.let(::utcMillisToLocalDateString),
dateRangePickerState.selectedEndDateMillis?.let(::utcMillisToLocalDateString),
),
modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 12.dp),
color = TppTheme.colors.muted,
)
},
showModeToggle = false,
)
}
}
}
@Composable
@@ -1227,13 +1527,65 @@ private fun LeaveTextField(label: String, value: String, onValue: (String) -> Un
private fun leaveDateRange(from: String?, to: String?): String =
if (from == to) from ?: "-" else "${from ?: "?"} - ${to ?: "?"}"
private fun localDateStringToUtcMillis(value: String): Long? =
runCatching {
LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
}.getOrNull()
private fun leaveCalendarMonths(loadedUntil: String?): List<YearMonth> {
val first = YearMonth.from(LocalDate.now())
val last = loadedUntil
?.let { runCatching { YearMonth.from(LocalDate.parse(it)) }.getOrNull() }
?: first
val months = mutableListOf<YearMonth>()
var cursor = first
while (!cursor.isAfter(last)) {
months += cursor
cursor = cursor.plusMonths(1)
}
return months
}
private fun utcMillisToLocalDateString(value: Long): String =
Instant.ofEpochMilli(value).atZone(ZoneOffset.UTC).toLocalDate().toString()
private fun leaveCalendarWeekdays(): List<DayOfWeek> =
listOf(
DayOfWeek.MONDAY,
DayOfWeek.TUESDAY,
DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY,
DayOfWeek.FRIDAY,
DayOfWeek.SATURDAY,
DayOfWeek.SUNDAY,
)
private fun leaveCalendarWeekdayLabel(day: DayOfWeek): String =
when (day) {
DayOfWeek.MONDAY -> "pon"
DayOfWeek.TUESDAY -> "wt"
DayOfWeek.WEDNESDAY -> "śr"
DayOfWeek.THURSDAY -> "czw"
DayOfWeek.FRIDAY -> "pt"
DayOfWeek.SATURDAY -> "sob"
DayOfWeek.SUNDAY -> "nd"
}
private fun monthDaysGrid(month: YearMonth): List<LocalDate?> {
val first = month.atDay(1)
val leadingBlanks = first.dayOfWeek.value - DayOfWeek.MONDAY.value
val days = mutableListOf<LocalDate?>()
repeat(leadingBlanks) { days += null }
for (day in 1..month.lengthOfMonth()) {
days += month.atDay(day)
}
while (days.size % 7 != 0) {
days += null
}
return days
}
private fun leaveCalendarDateInRange(date: LocalDate, selectedFrom: String, selectedTo: String, hasSelection: Boolean): Boolean {
if (!hasSelection) return false
val from = runCatching { LocalDate.parse(selectedFrom) }.getOrNull() ?: return false
val to = runCatching { LocalDate.parse(selectedTo) }.getOrNull() ?: return false
return !date.isBefore(from) && !date.isAfter(to)
}
private fun leaveCalendarMonthTitle(month: YearMonth): String =
DateTimeFormatter.ofPattern("LLLL yyyy", Locale("pl", "PL")).format(month.atDay(1))
private fun shortDateTime(value: String): String =
runCatching {
@@ -1930,6 +2282,9 @@ private fun RouteStageScreen(
stage: String,
weightLabel: String,
submitLabel: String,
showWeightInput: Boolean = true,
showPhotoActions: Boolean = true,
showPhotoGrid: Boolean = true,
onBack: () -> Unit,
onWeightChange: (String) -> Unit,
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
@@ -1944,11 +2299,23 @@ private fun RouteStageScreen(
val route = state.displaySelectedRoute
val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty()
val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
val submitBlocker = routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size)
val submitBlocker = when {
!showWeightInput -> loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
stage == "loading" -> loadingWeightSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
else -> routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
}
val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -2004,71 +2371,82 @@ private fun RouteStageScreen(
}
item {
RouteStageChecklist(
hasValidWeight = routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = 1,
localUploadCount = 0,
) == null,
photoCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size),
hasValidWeight = if (showWeightInput) {
routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = 1,
localUploadCount = 0,
) == null
} else {
null
},
photoCount = attachmentCount,
photoLabel = if (stage == "loading") "Zdjęcie załadunku" else "Zdjęcie etapu",
)
}
item {
OutlinedTextField(
value = state.routeStageWeightText,
onValueChange = onWeightChange,
label = { Text(weightLabel) },
trailingIcon = {
Text(
"kg",
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
},
supportingText = {
if (weightBlocker != null) {
Text(weightBlocker)
}
},
isError = weightBlocker != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (showWeightInput) {
item {
OutlinedTextField(
value = state.routeStageWeightText,
onValueChange = onWeightChange,
label = { Text(weightLabel) },
trailingIcon = {
Text(
"kg",
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
},
supportingText = {
if (weightBlocker != null) {
Text(weightBlocker)
}
},
isError = weightBlocker != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) {
val newUri = createCameraUri(context)
cameraUri = newUri
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(missingPermissions)
if (showPhotoActions) {
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) {
val newUri = createCameraUri(context)
cameraUri = newUri
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(missingPermissions)
}
}
}
if (state.allowGalleryUploads) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) {
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
if (state.allowGalleryUploads) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) {
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
}
}
}
}
item {
PhotoGrid(
photos = stagePhotos,
uploads = stageUploads,
deletingPhotoIds = state.deletingPhotoIds,
imageAuthHeader = state.imageAuthHeader,
onPhoto = onPhoto,
onDeletePhoto = {},
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
if (showPhotoGrid) {
item {
PhotoGrid(
photos = stagePhotos,
uploads = stageUploads,
deletingPhotoIds = state.deletingPhotoIds,
imageAuthHeader = state.imageAuthHeader,
onPhoto = onPhoto,
onDeletePhoto = {},
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
)
}
}
item { ErrorText(state.error) }
}
@@ -2155,7 +2533,7 @@ private fun RouteStageSummaryFact(label: String, value: String, modifier: Modifi
}
@Composable
private fun RouteStageChecklist(hasValidWeight: Boolean, photoCount: Int) {
private fun RouteStageChecklist(hasValidWeight: Boolean?, photoCount: Int, photoLabel: String) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline),
@@ -2169,13 +2547,15 @@ private fun RouteStageChecklist(hasValidWeight: Boolean, photoCount: Int) {
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
if (hasValidWeight != null) {
RouteStageRequirementRow(
label = "Waga",
done = hasValidWeight,
detail = if (hasValidWeight) "uzupełniona" else "wpisz wartość w kg",
)
}
RouteStageRequirementRow(
label = "Waga",
done = hasValidWeight,
detail = if (hasValidWeight) "uzupełniona" else "wpisz wartość w kg",
)
RouteStageRequirementRow(
label = "Zdjęcie etapu",
label = photoLabel,
done = photoCount > 0,
detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else "dodaj minimum jedno",
)
@@ -2430,9 +2810,16 @@ private fun RouteFlowStepper(steps: List<RouteFlowStepUi>) {
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
steps.forEach { step ->
RouteFlowStepItem(step, Modifier.weight(1f))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
steps.chunked(2).forEach { rowSteps ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowSteps.forEach { step ->
RouteFlowStepItem(step, Modifier.weight(1f))
}
if (rowSteps.size == 1) {
Spacer(Modifier.weight(1f))
}
}
}
}
}
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
data class LeaveRequestsConfig(
val enabled: Boolean = false,
@@ -52,4 +53,28 @@ object DriverLeaveRequestUiRules {
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today)
}
fun calendarEntriesForDate(entries: List<DriverLeaveCalendarEntryDto>, date: LocalDate): List<DriverLeaveCalendarEntryDto> =
entries.filter { entry ->
val from = entry.dateFrom?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val to = entry.dateTo?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
from != null && to != null && !date.isBefore(from) && !date.isAfter(to)
}
fun phoneDialUri(phoneNumber: String?): String? {
val raw = phoneNumber?.trim().orEmpty()
if (raw.isBlank()) return null
val digits = raw.filter { it.isDigit() }
if (digits.isBlank()) return null
val normalized = when {
digits.startsWith("00") -> "+${digits.drop(2)}"
raw.startsWith("+") -> "+$digits"
digits.length == 9 -> "+48$digits"
else -> "+$digits"
}
return "tel:$normalized"
}
}
@@ -77,6 +77,26 @@ fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUplo
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null
}
fun loadingPhotosSubmitBlocker(serverPhotoCount: Int, localUploadCount: Int): String? =
if (visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0) {
"Dodaj co najmniej jedno zdjęcie załadunku."
} else {
null
}
fun loadingWeightSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
return when {
trimmed.isBlank() -> "Podaj wagę."
normalizedWeight == null -> "Podaj poprawną wagę."
normalizedWeight <= 0.0 -> "Waga musi być większa od zera."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null
}
}
fun routeStageSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
@@ -127,7 +147,15 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): List<RouteFlowStepUi> {
val startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
val loadingState = startOverride ?: when {
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
val loadingPhotosState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete
else -> RouteFlowStepState.Todo
}
val loadingWeightState = startOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete
@@ -147,7 +175,8 @@ fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): Lis
}
return listOf(
RouteFlowStepUi("loading", "Załadunek", loadingState, routeFlowStateLabel(loadingState)),
RouteFlowStepUi("loading_photos", "Zdjęcia załadunku", loadingPhotosState, routeFlowStateLabel(loadingPhotosState)),
RouteFlowStepUi("loading_weight", "Waga załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState)),
RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState)),
)
@@ -21,6 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto
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
@@ -37,7 +38,7 @@ import pl.firmatpp.kierowca.sync.DriverSyncWorker
import pl.firmatpp.kierowca.tracking.ActiveRouteTrackingService
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, StartRoute, FinishRoute, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest }
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, StartRoute, LoadingWeight, FinishRoute, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, LeaveCalendar, AddLeaveRequest }
data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing,
@@ -74,6 +75,11 @@ data class DriverUiState(
val leaveRequestDateTo: String = LocalDate.now().toString(),
val leaveRequestType: String = "URLOP",
val leaveRequestNote: String = "",
val leaveCalendarEntries: List<DriverLeaveCalendarEntryDto> = emptyList(),
val leaveCalendarLoading: Boolean = false,
val leaveCalendarLoadedUntil: String? = null,
val leaveCalendarHasSelection: Boolean = false,
val leaveCalendarSelectionAnchor: String? = null,
val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false,
val imageAuthHeader: String? = null,
@@ -311,7 +317,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
screen = targetScreen,
selectedRoute = response.route,
routeStageWeightText = when (targetScreen) {
DriverScreen.StartRoute -> response.route.loadingWeight?.toString().orEmpty()
DriverScreen.LoadingWeight -> response.route.loadingWeight?.toString().orEmpty()
DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty()
else -> it.routeStageWeightText
},
@@ -448,15 +454,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val today = LocalDate.now().toString()
_state.update {
it.copy(
screen = DriverScreen.AddLeaveRequest,
screen = DriverScreen.LeaveCalendar,
leaveRequestDateFrom = today,
leaveRequestDateTo = today,
leaveRequestType = config?.types?.firstOrNull() ?: "URLOP",
leaveRequestNote = "",
leaveCalendarEntries = emptyList(),
leaveCalendarLoadedUntil = null,
leaveCalendarHasSelection = false,
leaveCalendarSelectionAnchor = null,
error = null,
feedback = null,
)
}
loadMoreLeaveCalendarMonths(reset = true)
}
fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) {
@@ -472,6 +483,99 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun selectLeaveCalendarDate(date: String) {
val selected = runCatching { LocalDate.parse(date) }.getOrNull() ?: return
if (selected.isBefore(LocalDate.now())) return
_state.update {
val anchor = it.leaveCalendarSelectionAnchor
if (!it.leaveCalendarHasSelection || anchor == null) {
it.copy(
leaveRequestDateFrom = date,
leaveRequestDateTo = date,
leaveCalendarHasSelection = true,
leaveCalendarSelectionAnchor = date,
error = null,
)
} else {
val start = LocalDate.parse(anchor)
val from = minOf(start, selected).toString()
val to = maxOf(start, selected).toString()
it.copy(
leaveRequestDateFrom = from,
leaveRequestDateTo = to,
leaveCalendarHasSelection = true,
leaveCalendarSelectionAnchor = null,
error = null,
)
}
}
}
fun continueAddLeaveRequestFromCalendar() {
if (!_state.value.leaveCalendarHasSelection) {
_state.update { it.copy(error = "Wybierz termin urlopu w kalendarzu.") }
return
}
_state.update { it.copy(screen = DriverScreen.AddLeaveRequest, error = null, feedback = null) }
}
fun reopenLeaveCalendarFromDraft() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
_state.update {
it.copy(
screen = DriverScreen.LeaveCalendar,
error = null,
feedback = null,
)
}
if (_state.value.leaveCalendarLoadedUntil == null) {
loadMoreLeaveCalendarMonths(reset = true)
}
}
fun loadMoreLeaveCalendarMonths(reset: Boolean = false) {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig) || snapshot.leaveCalendarLoading) return
val from = if (reset || snapshot.leaveCalendarLoadedUntil == null) {
LocalDate.now()
} else {
runCatching { LocalDate.parse(snapshot.leaveCalendarLoadedUntil).plusDays(1) }.getOrDefault(LocalDate.now())
}
val to = from.plusMonths(6).minusDays(1)
viewModelScope.launch {
_state.update {
it.copy(
leaveCalendarLoading = true,
error = null,
leaveCalendarEntries = if (reset) emptyList() else it.leaveCalendarEntries,
leaveCalendarLoadedUntil = if (reset) null else it.leaveCalendarLoadedUntil,
)
}
runCatching { repository.leaveCalendar(from.toString(), to.toString()) }
.onSuccess { entries ->
_state.update {
val merged = (it.leaveCalendarEntries + entries)
.distinctBy { entry -> entry.id }
.sortedWith(compareBy<DriverLeaveCalendarEntryDto> { entry -> entry.dateFrom.orEmpty() }.thenBy { entry -> entry.id })
it.copy(
leaveCalendarEntries = merged,
leaveCalendarLoadedUntil = to.toString(),
leaveCalendarLoading = false,
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) }
}
}
}
fun submitLeaveRequest() {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
@@ -545,6 +649,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update {
it.copy(
screen = DriverScreen.StartRoute,
routeStageWeightText = "",
feedback = null,
error = null,
)
}
}
fun openLoadingWeight() {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
val stagePhotos = routePhotosForStage(route.photos, "loading")
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" }
val blocker = loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
if (blocker != null) {
_state.update { it.copy(error = blocker) }
return
}
_state.update {
it.copy(
screen = DriverScreen.LoadingWeight,
routeStageWeightText = route.loadingWeight?.toString().orEmpty(),
feedback = null,
error = null,
@@ -584,16 +712,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val weight = snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull()
val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
if (
weight == null ||
!canSubmitRouteStageForm(
val submitBlocker = if (stage == "loading") {
loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
) {
_state.update { it.copy(error = "Podaj wagę i dodaj co najmniej jedno zdjęcie.") }
} else {
routeStageSubmitBlocker(
weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size,
)
}
if (weight == null || submitBlocker != null) {
_state.update { it.copy(error = submitBlocker ?: "Podaj poprawną wagę.") }
return
}
@@ -792,9 +926,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null)
DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> {
photoUploadsJob?.cancel()
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
class DriverLeaveRequestUiRulesTest {
@Test
@@ -48,4 +50,40 @@ class DriverLeaveRequestUiRulesTest {
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow))
}
@Test
fun findsCalendarEntriesThatOverlapSelectedDate() {
val entries = listOf(
calendarEntry(id = "week", dateFrom = "2026-08-03", dateTo = "2026-08-09"),
calendarEntry(id = "single-day", dateFrom = "2026-08-05", dateTo = "2026-08-05"),
calendarEntry(id = "outside", dateFrom = "2026-08-10", dateTo = "2026-08-12"),
)
val matching = DriverLeaveRequestUiRules.calendarEntriesForDate(entries, LocalDate.parse("2026-08-05"))
assertEquals(listOf("week", "single-day"), matching.map { it.id })
}
@Test
fun normalizesPhoneNumberIntoDialerUri() {
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("+48 600 700 800"))
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("0048 600 700 800"))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(null))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(" "))
}
private fun calendarEntry(id: String, dateFrom: String, dateTo: String): DriverLeaveCalendarEntryDto =
DriverLeaveCalendarEntryDto(
id = id,
dateFrom = dateFrom,
dateTo = dateTo,
type = "URLOP",
typeLabel = "Urlop",
status = "approved",
driver = DriverLeaveCalendarDriverDto(
id = "driver-$id",
displayName = "Kierowca $id",
phoneNumber = "+48 600 700 800",
),
)
}
@@ -118,7 +118,7 @@ class DriverRouteProjectionTest {
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[2].state)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
}
@@ -133,7 +133,7 @@ class DriverRouteProjectionTest {
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[0].state)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
}
@Test
@@ -295,14 +295,27 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(listOf("Załadunek", "W trasie", "Rozładunek"), steps.map { it.title })
assertEquals(listOf("Zdjęcia załadunku", "Waga załadunku", "W trasie", "Rozładunek"), steps.map { it.title })
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozpocznij kurs", action?.label)
assertEquals(RouteLifecycleAction.Start, action?.action)
}
@Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.LocalComplete, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun activeRouteShowsTransitStateAndFinishAction() {
val route = route(status = "W TRAKCIE", loadingWeight = 12.5)
@@ -311,8 +324,9 @@ class DriverUiRulesTest {
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Zakończ kurs", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action)
}
@@ -327,6 +341,7 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.Confirmed, steps[2].state)
assertEquals(RouteFlowStepState.Confirmed, steps[3].state)
assertEquals(null, action)
}
@@ -337,7 +352,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(actions)
assertEquals(RouteFlowStepState.Syncing, steps[0].state)
assertEquals(RouteFlowStepState.Syncing, steps[1].state)
assertEquals("Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu.", callout?.text)
assertFalse(callout?.isConflict ?: true)
}
@@ -349,7 +364,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(actions)
assertEquals(RouteFlowStepState.NeedsAttention, steps[2].state)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
}
@@ -362,6 +377,28 @@ class DriverUiRulesTest {
assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
@Test
fun loadingPhotosStepRequiresAtLeastOneLoadingPhotoBeforeWeightStep() {
assertEquals(
"Dodaj co najmniej jedno zdjęcie załadunku.",
loadingPhotosSubmitBlocker(serverPhotoCount = 0, localUploadCount = 0),
)
assertEquals(null, loadingPhotosSubmitBlocker(serverPhotoCount = 1, localUploadCount = 0))
assertEquals(null, loadingPhotosSubmitBlocker(serverPhotoCount = 0, localUploadCount = 1))
}
@Test
fun loadingWeightStepRequiresPositiveWeightAndPreviousLoadingPhoto() {
assertEquals("Podaj wagę.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj poprawną wagę.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals(
"Dodaj co najmniej jedno zdjęcie załadunku.",
loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0),
)
assertEquals(null, loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
private fun route(
status: String,
loadingWeight: Double? = null,