From 8a0651e578f38d3a71c7315c838c1e745cad79ab Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 9 Jul 2026 00:05:57 +0200 Subject: [PATCH] =?UTF-8?q?Rozdziel=20za=C5=82adunek=20i=20dodaj=20kalenda?= =?UTF-8?q?rz=20urlop=C3=B3w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../kierowca/data/DriverRepository.kt | 4 + .../kierowca/data/api/MobileDriverApi.kt | 8 + .../kierowca/data/model/DriverModels.kt | 20 + .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 679 ++++++++++++++---- .../kierowca/ui/DriverLeaveRequestUiRules.kt | 25 + .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 33 +- .../firmatpp/kierowca/ui/DriverViewModel.kt | 154 +++- .../ui/DriverLeaveRequestUiRulesTest.kt | 38 + .../kierowca/ui/DriverRouteProjectionTest.kt | 4 +- .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 47 +- 11 files changed, 850 insertions(+), 166 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2649ace..ccd6072 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index 67e689d..1360524 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -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 = api.leaveRequests(authHeader(requireToken())).data + suspend fun leaveCalendar(from: String, to: String): List = + api.leaveCalendar(authHeader(requireToken()), from, to).data + suspend fun leaveRequest(id: String): DriverLeaveRequestDto = api.leaveRequest(authHeader(requireToken()), id).data diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index bc606fa..c2dfccd 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -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, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index 16e8d14..74933df 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -167,6 +167,10 @@ data class LeaveRequestListResponse( val data: List = emptyList(), ) +data class LeaveCalendarResponse( + val data: List = emptyList(), +) + data class LeaveRequestResponse( val data: DriverLeaveRequestDto, ) @@ -206,6 +210,22 @@ data class DriverLeaveRequestDto( val events: List = 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, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 8457a45..b9935c5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -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(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, + selectedFrom: String, + selectedTo: String, + hasSelection: Boolean, + onDayClick: (LocalDate, List) -> 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, + 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, + 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 { + val first = YearMonth.from(LocalDate.now()) + val last = loadedUntil + ?.let { runCatching { YearMonth.from(LocalDate.parse(it)) }.getOrNull() } + ?: first + val months = mutableListOf() + 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 = + 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 { + val first = month.atDay(1) + val leadingBlanks = first.dayOfWeek.value - DayOfWeek.MONDAY.value + val days = mutableListOf() + 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) { 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)) + } + } } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt index eebc81b..24d1c4a 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt @@ -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, date: LocalDate): List = + 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" + } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index eb84a53..afb41a7 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -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): RouteSyncCalloutUi? { fun routeFlowSteps(route: DriverRouteDto, actions: List): List { 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): 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)), ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index acbe639..865351a 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -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 = emptyList(), + val leaveCalendarLoading: Boolean = false, + val leaveCalendarLoadedUntil: String? = null, + val leaveCalendarHasSelection: Boolean = false, + val leaveCalendarSelectionAnchor: String? = null, val deletingPhotoIds: Set = 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 { 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() diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt index 36cd6f2..d1a656e 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt @@ -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", + ), + ) } diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt index a31c693..b6bb35d 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt @@ -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 diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index e2ba269..578940f 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -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,