Compare commits

..
21 changed files with 1427 additions and 247 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 50
versionName = "1.0.44"
versionCode = 104
versionName = "1.0.51"
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,14 +62,17 @@ 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
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data
suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
@@ -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,
)
@@ -189,6 +193,7 @@ data class CreateLeaveRequestBody(
data class CancelLeaveRequestBody(
val comment: String? = null,
val expectedVersion: Long? = null,
)
data class DriverLeaveRequestDto(
@@ -200,12 +205,32 @@ data class DriverLeaveRequestDto(
val typeLabel: String? = null,
val note: String? = null,
val status: String,
val version: Long = 1,
val updatedAt: String? = null,
val allowedDecisions: List<String> = emptyList(),
val canCancel: Boolean = false,
val submittedAt: String? = null,
val decidedAt: String? = null,
val decisionComment: String? = null,
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 +249,9 @@ data class RealtimeStatusBody(
data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val unloadingDate: String? = null,
val ordering: Int? = null,
val dayOrderings: Map<String, Int>? = emptyMap(),
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,8 +73,8 @@ class DriverSyncRepository(
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
(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(
@@ -85,6 +86,7 @@ class DriverSyncRepository(
),
)
}
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
repository.syncState(date, routeId)
@@ -96,6 +98,11 @@ class DriverSyncRepository(
suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope ->
saveSyncScope(scope)
}
}
suspend fun saveSyncScope(scope: SyncScopeDto) {
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
@@ -109,7 +116,6 @@ class DriverSyncRepository(
),
)
}
}
suspend fun clearCache() {
dao.clearBootstrap()
@@ -146,11 +152,24 @@ 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"
const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
const val SCOPE_LEAVE_CALENDAR = "leave_calendar"
}
}
@@ -14,7 +14,10 @@ class PhotoUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).photoUploadDao()
private val database = DriverDatabase.get(appContext)
private val dao = database.photoUploadDao()
private val routeActionDao = database.routeActionDao()
private val routeActionOutbox = RouteActionOutbox(appContext)
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
@@ -62,6 +65,7 @@ class PhotoUploadWorker(
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
@@ -90,6 +94,12 @@ class PhotoUploadWorker(
}
}
private suspend fun enqueueRouteActionsWaitingForPhoto(clientRequestId: String) {
routeActionDao.waitingForPhotosActions()
.filter { routeActionReferencesPhotoClientRequest(it, clientRequestId) }
.forEach { routeActionOutbox.enqueueWorker(it.clientActionId) }
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
@@ -14,6 +14,9 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity?
@Query("SELECT * FROM route_actions WHERE status = 'WAITING_FOR_PHOTOS'")
suspend fun waitingForPhotosActions(): List<RouteActionEntity>
@Query(
"""
SELECT * FROM route_actions
@@ -53,7 +53,7 @@ class RouteActionOutbox(
return entity
}
private fun enqueueWorker(clientActionId: String) {
fun enqueueWorker(clientActionId: String) {
val request = OneTimeWorkRequestBuilder<RouteActionWorker>()
.setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.upload
import com.google.gson.Gson
private val routeActionPhotoDependencyGson = Gson()
fun routeActionReferencesPhotoClientRequest(action: RouteActionEntity, clientRequestId: String): Boolean {
val needle = clientRequestId.trim()
if (needle.isBlank()) return false
return runCatching {
routeActionPhotoDependencyGson
.fromJson(action.photoClientRequestIdsJson, Array<String>::class.java)
?.any { it.trim() == needle }
?: false
}.getOrDefault(false)
}
@@ -272,10 +272,11 @@ class DriverLiveSyncClient(
private fun subscribe(socket: WebSocket, socketId: String) {
val id = synchronized(lock) { driverId } ?: return
val channel = "private-driver-mobile.$id"
val channels = listOf("private-driver-mobile.$id", "private-driver-mobile.0")
scope.launch {
runCatching {
channels.forEach { channel ->
val auth = gateway.broadcastAuth(socketId, channel).auth
if (!isCurrentSocket(socket)) return@launch
val payload = mapOf(
@@ -286,6 +287,7 @@ class DriverLiveSyncClient(
),
)
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
}
}.onFailure { throwable ->
AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}")
socket.close(1000, "subscription_failed")
@@ -52,6 +52,10 @@ class DriverSyncWorker(
syncRepository.saveSyncStates(response)
refreshed = true
}
DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
}
}
}
@@ -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(
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,14 +2427,20 @@ private fun RouteStageScreen(
}
item {
RouteStageChecklist(
hasValidWeight = routeStageSubmitBlocker(
hasValidWeight = if (showWeightInput) {
routeStageSubmitBlocker(
weightText = state.routeStageWeightText,
serverPhotoCount = 1,
localUploadCount = 0,
) == null,
photoCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size),
) == null
} else {
null
},
photoCount = attachmentCount,
photoLabel = if (stage == "loading") "Zdjęcie załadunku" else "Zdjęcie etapu",
)
}
if (showWeightInput) {
item {
OutlinedTextField(
value = state.routeStageWeightText,
@@ -2036,6 +2465,8 @@ private fun RouteStageScreen(
modifier = Modifier.fillMaxWidth(),
)
}
}
if (showPhotoActions) {
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) {
@@ -2058,6 +2489,8 @@ private fun RouteStageScreen(
}
}
}
}
if (showPhotoGrid) {
item {
PhotoGrid(
photos = stagePhotos,
@@ -2070,6 +2503,7 @@ private fun RouteStageScreen(
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 = "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,10 +2876,17 @@ private fun RouteFlowStepper(steps: List<RouteFlowStepUi>) {
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
steps.chunked(2).forEach { rowSteps ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
steps.forEach { step ->
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,68 @@ 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 routeDayOrdering(route: DriverRouteDto, date: String): Int =
route.dayOrderings?.get(date) ?: route.ordering ?: Int.MAX_VALUE
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 +119,7 @@ fun canStartRouteFromDriverApp(
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp(
@@ -70,13 +127,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 +174,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
}
@@ -114,8 +192,10 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
} else {
when (unresolved.status) {
RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu."
RouteActionStatus.Pending,
RouteActionStatus.Syncing -> "Trwa wysyłanie zmiany statusu kursu."
RouteActionStatus.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie."
else -> "Zmiana zapisana w telefonie. Wyślemypo odzyskaniu internetu."
else -> "Zmiana zapisana w telefonie. Aplikacja wyśle ją automatycznie."
}
},
isConflict = isConflict,
@@ -125,7 +205,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
@@ -145,9 +233,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))),
)
}
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate
import java.time.Instant
import pl.firmatpp.kierowca.data.AppPreferencesStore
@@ -21,6 +22,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 +39,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 +76,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 +97,10 @@ data class DriverUiState(
val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions)
.sortedWith(
compareBy<DriverRouteDto> { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 }
.thenBy { route -> routeDayOrdering(route, selectedDate) },
)
val selectedRouteProjection: ProjectedDriverRoute?
get() = selectedRoute?.let { projectDriverRoute(it, projectionActions) }
@@ -111,12 +122,17 @@ internal fun DriverUiState.withLoadedLeaveRequests(
requests: List<DriverLeaveRequestDto>,
navigateToList: Boolean,
): DriverUiState {
val currentById = leaveRequests.associateBy { it.id }
val merged = requests.map { incoming ->
currentById[incoming.id]?.takeIf { current -> current.version > incoming.version } ?: incoming
}.toMutableList()
leaveRequests.filter { current -> requests.none { it.id == current.id } }.forEach(merged::add)
val refreshedSelectedRequest = selectedLeaveRequest?.let { selected ->
requests.firstOrNull { request -> request.id == selected.id } ?: selected
merged.firstOrNull { request -> request.id == selected.id }?.takeIf { it.version >= selected.version } ?: selected
}
return copy(
screen = if (navigateToList) DriverScreen.LeaveRequests else screen,
leaveRequests = requests,
leaveRequests = merged,
selectedLeaveRequest = refreshedSelectedRequest,
error = null,
)
@@ -141,6 +157,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var routeActionsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null
private val leaveMutationMutex = Mutex()
private var pendingLeaveHint: DriverSyncHint? = null
private var leaveCalendarRequestGeneration: Long = 0
val state: StateFlow<DriverUiState> = _state
init {
@@ -311,7 +330,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
},
@@ -409,24 +428,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true, navigateToList = true)
viewModelScope.launch { loadLeaveRequests(showLoading = true, navigateToList = true) }
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false, navigateToList = false)
viewModelScope.launch { loadLeaveRequests(showLoading = false, navigateToList = false) }
}
private fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean) {
viewModelScope.launch {
private suspend fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean): Boolean {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
val result = runCatching { repository.leaveRequests() }
result.onSuccess { requests ->
_state.update { it.withLoadedLeaveRequests(requests, navigateToList) }
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(loading = false, refreshing = false) }
}
return result.isSuccess
}
fun openLeaveRequest(id: String) {
@@ -448,15 +465,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,16 +494,115 @@ 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)
val generation = ++leaveCalendarRequestGeneration
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 ->
if (generation != leaveCalendarRequestGeneration) return@onSuccess
_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 ->
if (generation != leaveCalendarRequestGeneration) return@onFailure
_state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) }
}
}
}
fun submitLeaveRequest() {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
if (!leaveMutationMutex.tryLock()) return
if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) {
_state.update { it.copy(error = "Data od nie może być z przeszłości.") }
leaveMutationMutex.unlock()
return
}
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
try {
runCatching {
repository.createLeaveRequest(
dateFrom = snapshot.leaveRequestDateFrom,
@@ -504,16 +625,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.withApiError(throwable) }
}
_state.update { it.copy(loading = false) }
} finally {
leaveMutationMutex.unlock()
consumePendingLeaveHint()
}
}
}
fun cancelSelectedLeaveRequest() {
val request = _state.value.selectedLeaveRequest ?: return
if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return
if (!leaveMutationMutex.tryLock()) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.cancelLeaveRequest(request.id) }
try {
runCatching { repository.cancelLeaveRequest(request.id, request.version) }
.onSuccess { updated ->
_state.update {
it.copy(
@@ -524,13 +651,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
.onFailure { throwable ->
val apiError = ApiErrorMapper.map(throwable)
if (apiError.kind == ApiErrorKind.Conflict && apiError.code == "leave_request_stale") {
runCatching { repository.leaveRequest(request.id) }
.onSuccess { current ->
_state.update {
it.withLoadedLeaveRequests(listOf(current), navigateToList = false)
.copy(error = "Wniosek został wcześniej zmieniony. Pokazujemy aktualny stan.")
}
}
.onFailure { refreshError -> _state.update { it.withApiError(refreshError) } }
} else {
_state.update { it.withApiError(throwable) }
}
}
_state.update { it.copy(loading = false) }
} finally {
leaveMutationMutex.unlock()
consumePendingLeaveHint()
}
}
}
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 +691,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 +732,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 +764,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 +951,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 +1002,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()
@@ -886,12 +1098,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value
val routeId = snapshot.selectedRoute?.id
val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return
val needsRefresh = remote.scopes.any { syncRepository.shouldRefresh(it) }
val staleScopes = remote.scopes.filter { syncRepository.shouldRefresh(it) }
if (needsRefresh) {
refreshCurrentScopeFromSyncState()
} else {
if (staleScopes.isEmpty()) {
syncRepository.saveSyncStates(remote)
return
}
staleScopes.forEach { scope ->
handleSyncHint(
DriverSyncHint(
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
version = scope.version,
checksum = scope.checksum,
),
)
}
}
@@ -922,10 +1145,55 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
if (leaveMutationMutex.isLocked) {
val pending = pendingLeaveHint
if (pending == null || hint.version > pending.version) pendingLeaveHint = hint
return
}
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
if (loadLeaveRequests(showLoading = false, navigateToList = false)) {
syncRepository.saveSyncScope(hint.asScope())
}
} else {
syncRepository.saveSyncScope(hint.asScope())
}
}
DriverSyncRepository.SCOPE_LEAVE_CALENDAR -> {
if (snapshot.screen == DriverScreen.LeaveCalendar) {
refreshLoadedLeaveCalendar(hint)
} else {
syncRepository.saveSyncScope(hint.asScope())
}
}
}
}
private suspend fun consumePendingLeaveHint() {
val hint = pendingLeaveHint ?: return
pendingLeaveHint = null
if (loadLeaveRequests(showLoading = false, navigateToList = false)) {
syncRepository.saveSyncScope(hint.asScope())
}
}
private suspend fun refreshLoadedLeaveCalendar(hint: DriverSyncHint) {
val generation = ++leaveCalendarRequestGeneration
val snapshot = _state.value
val from = LocalDate.now()
val loadedUntil = snapshot.leaveCalendarLoadedUntil
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?.takeUnless { it.isBefore(from) }
?: from.plusMonths(6).minusDays(1)
runCatching { repository.leaveCalendar(from.toString(), loadedUntil.toString()) }
.onSuccess { entries ->
if (generation != leaveCalendarRequestGeneration) return@onSuccess
_state.update { it.copy(leaveCalendarEntries = entries, leaveCalendarLoadedUntil = loadedUntil.toString(), leaveCalendarLoading = false) }
syncRepository.saveSyncScope(hint.asScope())
}
.onFailure { throwable ->
if (generation != leaveCalendarRequestGeneration) return@onFailure
_state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) }
}
}
@@ -0,0 +1,31 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RouteActionPhotoDependenciesTest {
@Test
fun detectsRouteActionWaitingForConfirmedPhotoClientRequest() {
val action = action("""["photo-a","photo-b"]""")
assertTrue(routeActionReferencesPhotoClientRequest(action, "photo-b"))
assertFalse(routeActionReferencesPhotoClientRequest(action, "photo-c"))
}
@Test
fun malformedPhotoRequestJsonDoesNotMatch() {
assertFalse(routeActionReferencesPhotoClientRequest(action("not-json"), "photo-a"))
}
private fun action(photoClientRequestIdsJson: String): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-1",
routeId = "route-1",
action = RouteActionType.Start,
weight = 12.5,
occurredAt = "2026-06-30T07:40:00Z",
photoClientRequestIdsJson = photoClientRequestIdsJson,
status = RouteActionStatus.WaitingForPhotos,
)
}
@@ -107,6 +107,21 @@ class DriverLiveSyncClientTest {
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") })
}
@Test
fun subscribesToOwnUpdatesAndSharedLeaveCalendarUpdates() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = this)
client.start("driver-1", config)
factory.sockets.single().message(connectionEstablished("socket-1"))
runCurrent()
val sent = factory.sockets.single().sent
assertTrue(sent.any { it.contains("private-driver-mobile.driver-1") })
assertTrue(sent.any { it.contains("private-driver-mobile.0") })
client.stop()
}
@Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory()
@@ -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",
@@ -55,6 +55,23 @@ class DriverUiStateTest {
assertEquals(1, refreshed.leaveRequests.size)
}
@Test
fun staleLeaveRequestRefreshDoesNotOverwriteNewerMutationResult() {
val state = DriverUiState(
screen = DriverScreen.LeaveRequestDetail,
leaveRequests = listOf(leaveRequest(id = "leave-1", status = "cancel_requested", version = 3)),
selectedLeaveRequest = leaveRequest(id = "leave-1", status = "cancel_requested", version = 3),
)
val refreshed = state.withLoadedLeaveRequests(
requests = listOf(leaveRequest(id = "leave-1", status = "approved", version = 2)),
navigateToList = false,
)
assertEquals("cancel_requested", refreshed.leaveRequests.single().status)
assertEquals(3L, refreshed.selectedLeaveRequest?.version)
}
@Test
fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() {
val route = route(status = "ZAPLANOWANA")
@@ -70,18 +87,54 @@ class DriverUiStateTest {
assertEquals(12.5, state.displaySelectedRoute?.loadingWeight)
}
private fun leaveRequest(id: String, status: String): DriverLeaveRequestDto =
@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 })
}
@Test
fun displayRoutesUsesTheSelectedDayOrderingForMultiDayRoutes() {
val state = DriverUiState(
selectedDate = "2026-07-02",
routes = listOf(
route(id = "span", status = "ZAPLANOWANA", ordering = 0, dayOrderings = mapOf("2026-07-02" to 2)),
route(id = "above-first", status = "ZAPLANOWANA", ordering = 0, dayOrderings = mapOf("2026-07-02" to 0)),
route(id = "above-second", status = "ZAPLANOWANA", ordering = 1, dayOrderings = mapOf("2026-07-02" to 1)),
),
)
assertEquals(listOf("above-first", "above-second", "span"), state.displayRoutes.map { it.id })
}
private fun leaveRequest(id: String, status: String, version: Long = 1): DriverLeaveRequestDto =
DriverLeaveRequestDto(
id = id,
dateFrom = "2026-07-10",
dateTo = "2026-07-12",
type = "URLOP",
status = status,
version = version,
)
private fun route(status: String): DriverRouteDto =
private fun route(
id: String = "1",
status: String,
ordering: Int? = null,
dayOrderings: Map<String, Int> = emptyMap(),
): DriverRouteDto =
DriverRouteDto(
id = "1",
id = id,
routeDate = "2026-06-30",
ordering = ordering,
dayOrderings = dayOrderings,
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",