Compare commits

..
Author SHA1 Message Date
admin e49e7c358e Obsłuż wielodniowe kursy kierowcy 2026-07-10 14:50:38 +02:00
admin 3267fee439 Usprawnij wybór urlopu przy konfliktach 2026-07-09 00:31:06 +02:00
admin 8a0651e578 Rozdziel załadunek i dodaj kalendarz urlopów 2026-07-09 00:05:57 +02:00
13 changed files with 1143 additions and 197 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 = 103
versionName = "1.0.50"
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,
@@ -224,6 +244,7 @@ data class RealtimeStatusBody(
data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val unloadingDate: String? = null,
val startsAt: String,
val originName: String,
val destinationName: String,
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.sync
import android.content.Context
import com.google.gson.Gson
import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
@@ -72,18 +73,19 @@ class DriverSyncRepository(
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
(routeVisibleDates(route) + listOfNotNull(date)).distinct().forEach { selectedDate ->
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return@forEach
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
}
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
@@ -146,6 +148,18 @@ class DriverSyncRepository(
private fun currentDateFallback(): String = java.time.LocalDate.now().toString()
private fun routeVisibleDates(route: DriverRouteDto): List<String> {
val start = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return emptyList()
val end = route.unloadingDate
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?.takeUnless { it.isBefore(start) }
?: start
return generateSequence(start) { date -> date.plusDays(1).takeIf { !it.isAfter(end) } }
.map(LocalDate::toString)
.toList()
}
companion object {
const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail"
@@ -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,24 +262,43 @@ 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 = "Załaduj",
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(
state = state,
title = "Zakończ kurs",
title = "Rozładunek",
stage = "unloading",
weightLabel = "Waga na rozładunku",
submitLabel = "Zakończ kurs",
submitLabel = "Rozładuj",
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") },
@@ -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,383 @@ 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 showConflictDetails by remember { mutableStateOf(false) }
val selectedConflictEntries = remember(
state.leaveCalendarEntries,
state.leaveCalendarHasSelection,
state.leaveRequestDateFrom,
state.leaveRequestDateTo,
) {
if (state.leaveCalendarHasSelection) {
DriverLeaveRequestUiRules.calendarEntriesForRange(
entries = state.leaveCalendarEntries,
dateFrom = state.leaveRequestDateFrom,
dateTo = state.leaveRequestDateTo,
)
} else {
emptyList()
}
}
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,
conflictCount = selectedConflictEntries.size,
onShowConflicts = { showConflictDetails = true },
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, _ ->
onDateSelected(date.toString())
},
)
}
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 (showConflictDetails && selectedConflictEntries.isNotEmpty()) {
ModalBottomSheet(
onDismissRequest = { showConflictDetails = false },
sheetState = sheetState,
containerColor = TppTheme.colors.card,
) {
LeaveCalendarDetailsSheet(
title = "Urlopy w wybranym terminie",
entries = selectedConflictEntries,
onCall = { dialUri ->
context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse(dialUri)))
},
)
}
}
}
@Composable
private fun LeaveCalendarContinueBar(
state: DriverUiState,
conflictCount: Int,
onShowConflicts: () -> Unit,
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)
}
}
if (state.leaveCalendarHasSelection && conflictCount > 0) {
Row(
Modifier
.fillMaxWidth()
.background(TppTheme.colors.warningContainer, RoundedCornerShape(4.dp))
.border(1.dp, TppTheme.colors.warningOutline, RoundedCornerShape(4.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Outlined.Info, contentDescription = null, tint = Color(0xFF7A5A00), modifier = Modifier.size(20.dp))
Text(
"Nakłada się z ${leaveConflictCountLabel(conflictCount)}",
color = Color(0xFF7A5A00),
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onShowConflicts) {
Text("Zobacz", color = TppTheme.colors.forest, 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(
title: String,
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(
title,
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 +1451,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 +1495,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 +1566,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 {
@@ -1639,6 +2030,8 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
)
}
}
Spacer(Modifier.height(12.dp))
RouteScheduleLabel(route)
Spacer(Modifier.height(if (compactWidth) 18.dp else 26.dp))
RouteTimeline(display.origin, display.destination)
}
@@ -1670,6 +2063,21 @@ private fun StatusChip(status: String, color: Color) {
)
}
@Composable
private fun RouteScheduleLabel(route: DriverRouteDto) {
Text(
routeScheduleSummary(route),
modifier = Modifier
.fillMaxWidth()
.background(TppTheme.colors.panel, RoundedCornerShape(4.dp))
.padding(horizontal = 10.dp, vertical = 8.dp),
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelMedium,
)
}
@Composable
private fun RouteTimeline(origin: String, destination: String) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
@@ -1930,6 +2338,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 +2355,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 +2427,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 +2589,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 +2603,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",
)
@@ -2355,9 +2791,19 @@ private fun RouteLifecycleSection(
val steps = routeFlowSteps(route, routeActions)
val callout = routeSyncCallout(routeActions)
val action = routeLifecyclePrimaryAction(route, selectedDate)
val instruction = routeTodayInstruction(route, selectedDate)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
RouteFlowStepper(steps)
if (instruction != null) {
RouteStateMessage(
text = instruction,
icon = Icons.Outlined.Info,
container = TppTheme.colors.panel,
outline = TppTheme.colors.outline,
color = TppTheme.colors.ink,
)
}
if (!feedback.isNullOrBlank()) {
RouteStateMessage(
text = feedback,
@@ -2430,9 +2876,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))
}
}
}
}
}
@@ -2459,6 +2912,15 @@ private fun RouteFlowStepItem(step: RouteFlowStepUi, modifier: Modifier = Modifi
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (step.dateLabel != null) {
Text(
step.dateLabel,
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.labelSmall,
)
}
Text(
step.stateLabel,
color = color,
@@ -2539,6 +3001,9 @@ private fun photoCountLabel(count: Int): String =
else -> "zdjęć"
}
private fun leaveConflictCountLabel(count: Int): String =
if (count == 1) "1 urlopem" else "$count urlopami"
@Composable
private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status)
@@ -2567,6 +3032,7 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig
fontWeight = FontWeight.Bold,
color = TppTheme.colors.ink,
)
RouteScheduleLabel(route)
}
}
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.65f))
@@ -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,41 @@ 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 calendarEntriesForRange(entries: List<DriverLeaveCalendarEntryDto>, dateFrom: String, dateTo: String): List<DriverLeaveCalendarEntryDto> {
val selectedFrom = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return emptyList()
val selectedTo = runCatching { LocalDate.parse(dateTo) }.getOrNull() ?: return emptyList()
val from = minOf(selectedFrom, selectedTo)
val to = maxOf(selectedFrom, selectedTo)
return entries.filter { entry ->
val entryFrom = entry.dateFrom?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
val entryTo = entry.dateTo?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
entryFrom != null && entryTo != null && !entryTo.isBefore(from) && !entryFrom.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"
}
}
@@ -34,6 +34,7 @@ data class RouteFlowStepUi(
val title: String,
val state: RouteFlowStepState,
val stateLabel: String,
val dateLabel: String? = null,
)
data class RouteLifecycleActionUi(
@@ -49,12 +50,65 @@ data class RouteSyncCalloutUi(
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
fun routeIsActiveToday(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean {
val viewedDate = runCatching { LocalDate.parse(selectedDate) }.getOrNull() ?: return false
val loadingDate = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return false
val unloadingDate = route.unloadingDate
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: loadingDate
return viewedDate == today && !today.isBefore(loadingDate) && !today.isAfter(unloadingDate)
}
fun routeUnloadingDate(route: DriverRouteDto): String? = route.unloadingDate ?: route.routeDate
fun routeScheduleDateLabel(date: String?): String =
date
?.let { runCatching { LocalDate.parse(it).format(shortDateFormatter) }.getOrNull() }
?: ""
fun routeScheduleSummary(route: DriverRouteDto): String {
val loading = routeScheduleDateLabel(route.routeDate)
val unloading = routeScheduleDateLabel(routeUnloadingDate(route))
return if (route.unloadingDate == null) {
"Załadunek: $loading · Rozładunek: ten sam dzień"
} else {
"Załadunek: $loading · Rozładunek: $unloading"
}
}
fun routeTodayInstruction(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): String? {
if (!routeIsActiveToday(route, selectedDate, today)) return null
val loadingDate = route.routeDate?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: return null
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
return when {
route.status == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
today == loadingDate -> "Dziś: załadunek."
today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
today == unloadingDate -> "Dziś: rozładunek."
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
}
}
fun canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp(
@@ -62,7 +116,7 @@ fun canStartRouteFromDriverApp(
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp(
@@ -70,13 +124,34 @@ fun canFinishRouteFromDriverApp(
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status == "W TRAKCIE"
route.status == "W TRAKCIE"
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean {
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()
@@ -96,8 +171,8 @@ fun routeLifecyclePrimaryAction(
today: LocalDate = LocalDate.now(),
): RouteLifecycleActionUi? =
when {
canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozpocznij kurs", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Zakończ kurs", RouteLifecycleAction.Finish)
canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Załaduj", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozładuj", RouteLifecycleAction.Finish)
else -> null
}
@@ -127,7 +202,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,9 +230,10 @@ 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), routeScheduleDateLabel(route.routeDate)),
RouteFlowStepUi("loading_weight", "Waga załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState), routeScheduleDateLabel(route.routeDate)),
RouteFlowStepUi("transit", "W trasie", transitState, routeFlowStateLabel(transitState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState), routeScheduleDateLabel(routeUnloadingDate(route))),
)
}
@@ -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,
@@ -90,6 +96,7 @@ data class DriverUiState(
val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions)
.sortedBy { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 }
val selectedRouteProjection: ProjectedDriverRoute?
get() = selectedRoute?.let { projectDriverRoute(it, projectionActions) }
@@ -311,7 +318,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 +455,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 +484,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
@@ -530,7 +635,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other") {
val route = _state.value.selectedRoute ?: return
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (!routeIsActiveToday(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Zdjęcia możesz dodać tylko w zaplanowanym zakresie kursu.") }
return
}
viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) }
@@ -541,10 +651,39 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun openStartRoute() {
val route = _state.value.selectedRoute ?: return
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return
}
_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,
@@ -553,7 +692,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun openFinishRoute() {
val route = _state.value.selectedRoute ?: return
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (!canFinishRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Rozładunek jest dostępny w dniu zaplanowanego rozładunku.") }
return
}
_state.update {
it.copy(
screen = DriverScreen.FinishRoute,
@@ -580,20 +724,41 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun submitRouteStage(stage: String) {
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
val canSubmitStage = if (stage == "loading") {
canStartRouteFromDriverApp(route, snapshot.selectedDate)
} else {
canFinishRouteFromDriverApp(route, snapshot.selectedDate)
}
if (!canSubmitStage) {
_state.update {
it.copy(error = if (stage == "loading") {
"Załadunek nie jest dostępny poza zaplanowanym zakresem kursu."
} else {
"Rozładunek jest dostępny w dniu zaplanowanego rozładunku."
})
}
return
}
val driver = snapshot.driver
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
}
@@ -746,8 +911,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return
if (_state.value.completingRoute || !_state.value.isOnline) return
val snapshot = _state.value
val route = snapshot.selectedRoute ?: return
if (snapshot.completingRoute || !snapshot.isOnline) return
if (!canCompleteRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Zakończenie kursu jest dostępne w dniu zaplanowanego rozładunku.") }
return
}
viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
@@ -792,9 +962,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,59 @@ 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 findsCalendarEntriesThatOverlapSelectedRange() {
val entries = listOf(
calendarEntry(id = "starts-before", dateFrom = "2026-08-01", dateTo = "2026-08-04"),
calendarEntry(id = "inside", dateFrom = "2026-08-06", dateTo = "2026-08-07"),
calendarEntry(id = "ends-after", dateFrom = "2026-08-09", dateTo = "2026-08-12"),
calendarEntry(id = "before", dateFrom = "2026-07-20", dateTo = "2026-07-22"),
calendarEntry(id = "after", dateFrom = "2026-08-13", dateTo = "2026-08-14"),
)
val matching = DriverLeaveRequestUiRules.calendarEntriesForRange(
entries = entries,
dateFrom = "2026-08-03",
dateTo = "2026-08-10",
)
assertEquals(listOf("starts-before", "inside", "ends-after"), 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
@@ -154,6 +154,7 @@ class DriverRouteProjectionTest {
): DriverRouteDto =
DriverRouteDto(
id = "1",
routeDate = "2026-06-30",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
@@ -39,6 +39,32 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test
fun multiDayRouteCanLoadTodayButCanUnloadOnlyOnItsUnloadingDate() {
val route = route(
status = "ZAPLANOWANA",
routeDate = "2026-06-29",
unloadingDate = "2026-06-30",
)
assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today))
assertEquals("Załaduj", routeLifecyclePrimaryAction(route, "2026-06-30", today)?.label)
assertEquals("Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj.", routeTodayInstruction(route, "2026-06-30", today))
val activeRoute = route.copy(status = "W TRAKCIE")
assertTrue(canFinishRouteFromDriverApp(activeRoute, "2026-06-30", today))
assertEquals("Rozładuj", routeLifecyclePrimaryAction(activeRoute, "2026-06-30", today)?.label)
assertFalse(canFinishRouteFromDriverApp(activeRoute, "2026-06-29", LocalDate.parse("2026-06-29")))
}
@Test
fun routeScheduleShowsBothDispatcherDates() {
val route = route(routeDate = "2026-06-30", unloadingDate = "2026-07-02")
assertEquals("Załadunek: 30.06 · Rozładunek: 02.07", routeScheduleSummary(route))
assertEquals("Załadunek: 30.06 · Rozładunek: ten sam dzień", routeScheduleSummary(route.copy(unloadingDate = null)))
}
@Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
@@ -295,14 +321,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("Rozpocznij kurs", action?.label)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Załaduj", 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,9 +350,10 @@ 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("Zakończ kurs", action?.label)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action)
}
@@ -327,6 +367,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 +378,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 +390,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,14 +403,40 @@ 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,
status: String = "ZAPLANOWANA",
loadingWeight: Double? = null,
unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(),
routeDate: String? = "2026-06-30",
unloadingDate: String? = null,
): DriverRouteDto =
DriverRouteDto(
id = "1",
routeDate = routeDate,
unloadingDate = unloadingDate,
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
@@ -70,6 +70,19 @@ class DriverUiStateTest {
assertEquals(12.5, state.displaySelectedRoute?.loadingWeight)
}
@Test
fun displayRoutesMovesCompletedRoutesBelowActiveDispatcherOrder() {
val state = DriverUiState(
routes = listOf(
route(id = "completed", status = "ZAKOŃCZONA"),
route(id = "planned", status = "ZAPLANOWANA"),
route(id = "active", status = "W TRAKCIE"),
),
)
assertEquals(listOf("planned", "active", "completed"), state.displayRoutes.map { it.id })
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
@@ -79,9 +92,10 @@ class DriverUiStateTest {
status = status,
)
private fun route(status: String): DriverRouteDto =
private fun route(id: String = "1", status: String): DriverRouteDto =
DriverRouteDto(
id = "1",
id = id,
routeDate = "2026-06-30",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",