Compare commits

..
22 changed files with 1502 additions and 253 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 47 versionCode = 104
versionName = "1.0.44" versionName = "1.0.51"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto 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.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
@@ -61,14 +62,17 @@ class DriverRepository(
suspend fun leaveRequests(): List<DriverLeaveRequestDto> = suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data 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 = suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto = suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto = suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data
suspend fun completeRoute(routeId: String) = suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId) 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.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.FinishRouteBody 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.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
@@ -76,6 +77,13 @@ interface MobileDriverApi {
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
): LeaveRequestListResponse ): 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") @POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest( suspend fun createLeaveRequest(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -167,6 +167,10 @@ data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(), val data: List<DriverLeaveRequestDto> = emptyList(),
) )
data class LeaveCalendarResponse(
val data: List<DriverLeaveCalendarEntryDto> = emptyList(),
)
data class LeaveRequestResponse( data class LeaveRequestResponse(
val data: DriverLeaveRequestDto, val data: DriverLeaveRequestDto,
) )
@@ -189,6 +193,7 @@ data class CreateLeaveRequestBody(
data class CancelLeaveRequestBody( data class CancelLeaveRequestBody(
val comment: String? = null, val comment: String? = null,
val expectedVersion: Long? = null,
) )
data class DriverLeaveRequestDto( data class DriverLeaveRequestDto(
@@ -200,12 +205,32 @@ data class DriverLeaveRequestDto(
val typeLabel: String? = null, val typeLabel: String? = null,
val note: String? = null, val note: String? = null,
val status: String, 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 submittedAt: String? = null,
val decidedAt: String? = null, val decidedAt: String? = null,
val decisionComment: String? = null, val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(), 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( data class DriverLeaveRequestEventDto(
val id: String, val id: String,
val action: String, val action: String,
@@ -224,6 +249,9 @@ data class RealtimeStatusBody(
data class DriverRouteDto( data class DriverRouteDto(
val id: String, val id: String,
val routeDate: String? = null, val routeDate: String? = null,
val unloadingDate: String? = null,
val ordering: Int? = null,
val dayOrderings: Map<String, Int>? = emptyMap(),
val startsAt: String, val startsAt: String,
val originName: String, val originName: String,
val destinationName: String, val destinationName: String,
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.sync
import android.content.Context import android.content.Context
import com.google.gson.Gson import com.google.gson.Gson
import java.io.IOException import java.io.IOException
import java.time.LocalDate
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
@@ -72,18 +73,19 @@ class DriverSyncRepository(
), ),
) )
val selectedDate = date ?: return (routeVisibleDates(route) + listOfNotNull(date)).distinct().forEach { selectedDate ->
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return val cachedBootstrap = dao.bootstrap(selectedDate) ?: return@forEach
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java) val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item } val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap( dao.upsertBootstrap(
cachedBootstrap.copy( cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))), payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null, checksum = null,
version = null, version = null,
syncedAtEpochMillis = syncedAt, syncedAtEpochMillis = syncedAt,
), ),
) )
}
} }
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse = suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
@@ -96,21 +98,25 @@ class DriverSyncRepository(
suspend fun saveSyncStates(response: SyncStateResponse?) { suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope -> response?.scopes.orEmpty().forEach { scope ->
dao.upsertSyncState( saveSyncScope(scope)
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
checksum = scope.checksum,
version = scope.version,
computedAt = scope.computedAt,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
} }
} }
suspend fun saveSyncScope(scope: SyncScopeDto) {
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
checksum = scope.checksum,
version = scope.version,
computedAt = scope.computedAt,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
}
suspend fun clearCache() { suspend fun clearCache() {
dao.clearBootstrap() dao.clearBootstrap()
dao.clearRoutes() dao.clearRoutes()
@@ -146,11 +152,24 @@ class DriverSyncRepository(
private fun currentDateFallback(): String = java.time.LocalDate.now().toString() 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 { companion object {
const val SCOPE_ROUTES = "routes" const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail" const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings" const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet" const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests" const val SCOPE_LEAVE_REQUESTS = "leave_requests"
const val SCOPE_LEAVE_CALENDAR = "leave_calendar"
} }
} }
@@ -14,7 +14,10 @@ class PhotoUploadWorker(
appContext: Context, appContext: Context,
params: WorkerParameters, params: WorkerParameters,
) : CoroutineWorker(appContext, params) { ) : 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) private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
@@ -62,6 +65,7 @@ class PhotoUploadWorker(
} }
dao.markConfirmed(clientRequestId, receipt.serverPhotoId) dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
val error = ApiErrorMapper.map(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 { companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId" const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress" const val KEY_PROGRESS = "progress"
@@ -14,11 +14,14 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity? suspend fun find(clientActionId: String): RouteActionEntity?
@Query("SELECT * FROM route_actions WHERE status = 'WAITING_FOR_PHOTOS'")
suspend fun waitingForPhotosActions(): List<RouteActionEntity>
@Query( @Query(
""" """
SELECT * FROM route_actions SELECT * FROM route_actions
WHERE routeId = :routeId WHERE routeId = :routeId
AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT') AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'CONFIRMED', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
ORDER BY createdAtEpochMillis DESC ORDER BY createdAtEpochMillis DESC
""", """,
) )
@@ -28,6 +31,14 @@ interface RouteActionDao {
""" """
SELECT * FROM route_actions SELECT * FROM route_actions
WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT') WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
OR clientActionId IN (
SELECT confirmed.clientActionId
FROM route_actions AS confirmed
WHERE confirmed.status = 'CONFIRMED'
AND confirmed.routeId = route_actions.routeId
ORDER BY confirmed.createdAtEpochMillis DESC
LIMIT 2
)
ORDER BY createdAtEpochMillis DESC ORDER BY createdAtEpochMillis DESC
""", """,
) )
@@ -53,7 +53,7 @@ class RouteActionOutbox(
return entity return entity
} }
private fun enqueueWorker(clientActionId: String) { fun enqueueWorker(clientActionId: String) {
val request = OneTimeWorkRequestBuilder<RouteActionWorker>() val request = OneTimeWorkRequestBuilder<RouteActionWorker>()
.setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId)) .setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .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,20 +272,22 @@ class DriverLiveSyncClient(
private fun subscribe(socket: WebSocket, socketId: String) { private fun subscribe(socket: WebSocket, socketId: String) {
val id = synchronized(lock) { driverId } ?: return 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 { scope.launch {
runCatching { runCatching {
val auth = gateway.broadcastAuth(socketId, channel).auth channels.forEach { channel ->
if (!isCurrentSocket(socket)) return@launch val auth = gateway.broadcastAuth(socketId, channel).auth
val payload = mapOf( if (!isCurrentSocket(socket)) return@launch
"event" to "pusher:subscribe", val payload = mapOf(
"data" to mapOf( "event" to "pusher:subscribe",
"channel" to channel, "data" to mapOf(
"auth" to auth, "channel" to channel,
), "auth" to auth,
) ),
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" } )
check(socket.send(gson.toJson(payload))) { "WebSocket send returned false" }
}
}.onFailure { throwable -> }.onFailure { throwable ->
AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}") AppDiagnostics.log("realtime_subscription_error: ${throwable.message ?: throwable::class.java.simpleName}")
socket.close(1000, "subscription_failed") socket.close(1000, "subscription_failed")
@@ -52,6 +52,10 @@ class DriverSyncWorker(
syncRepository.saveSyncStates(response) syncRepository.saveSyncStates(response)
refreshed = true 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.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -91,7 +90,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDateRangePickerState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -99,6 +98,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 com.google.android.gms.common.api.Status
import java.io.File import java.io.File
import java.time.Instant import java.time.Instant
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.YearMonth
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import java.util.Locale import java.util.Locale
import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto 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.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.NavigationPointDto
@@ -259,24 +262,43 @@ fun DriverApp(
) )
DriverScreen.StartRoute -> RouteStageScreen( DriverScreen.StartRoute -> RouteStageScreen(
state = state, state = state,
title = "Rozpocznij kurs", title = "Zdjęcia załadunku",
stage = "loading", stage = "loading",
weightLabel = "Waga na załadunku", weightLabel = "Waga na załadunku",
submitLabel = "Rozpocznij kurs", submitLabel = "Gotowe, jadę na wagę",
showWeightInput = false,
showPhotoActions = true,
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") }, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "loading") },
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload, onDeleteUpload = viewModel::deleteConfirmedUpload,
onRetryUpload = viewModel::retryPhotoUpload, 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, onSubmit = viewModel::submitStartRoute,
) )
DriverScreen.FinishRoute -> RouteStageScreen( DriverScreen.FinishRoute -> RouteStageScreen(
state = state, state = state,
title = "Zakończ kurs", title = "Rozładunek",
stage = "unloading", stage = "unloading",
weightLabel = "Waga na rozładunku", weightLabel = "Waga na rozładunku",
submitLabel = "Zakończ kurs", submitLabel = "Rozładuj",
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") }, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") },
@@ -303,10 +325,18 @@ fun DriverApp(
onBack = viewModel::back, onBack = viewModel::back,
onCancel = viewModel::cancelSelectedLeaveRequest, 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( DriverScreen.AddLeaveRequest -> AddLeaveRequestScreen(
state = state, state = state,
onBack = viewModel::back, onBack = viewModel::back,
onDraft = viewModel::updateLeaveRequestDraft, onDraft = viewModel::updateLeaveRequestDraft,
onChangePeriod = viewModel::reopenLeaveCalendarFromDraft,
onSubmit = viewModel::submitLeaveRequest, 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 @Composable
private fun AddLeaveRequestScreen( private fun AddLeaveRequestScreen(
state: DriverUiState, state: DriverUiState,
onBack: () -> Unit, onBack: () -> Unit,
onDraft: (String?, String?, String?, String?) -> Unit, onDraft: (String?, String?, String?, String?) -> Unit,
onChangePeriod: () -> Unit,
onSubmit: () -> 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 -> Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppTheme.colors.surface) { padding ->
LazyColumn( LazyColumn(
Modifier.fillMaxSize().padding(padding), Modifier.fillMaxSize().padding(padding),
@@ -1063,7 +1451,7 @@ private fun AddLeaveRequestScreen(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline), border = BorderStroke(1.dp, TppTheme.colors.outline),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().clickable { showDateRangePicker = true }, modifier = Modifier.fillMaxWidth().clickable { onChangePeriod() },
) { ) {
Row( Row(
Modifier.fillMaxWidth().padding(16.dp), 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 @Composable
@@ -1227,13 +1566,65 @@ private fun LeaveTextField(label: String, value: String, onValue: (String) -> Un
private fun leaveDateRange(from: String?, to: String?): String = private fun leaveDateRange(from: String?, to: String?): String =
if (from == to) from ?: "-" else "${from ?: "?"} - ${to ?: "?"}" if (from == to) from ?: "-" else "${from ?: "?"} - ${to ?: "?"}"
private fun localDateStringToUtcMillis(value: String): Long? = private fun leaveCalendarMonths(loadedUntil: String?): List<YearMonth> {
runCatching { val first = YearMonth.from(LocalDate.now())
LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() val last = loadedUntil
}.getOrNull() ?.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 = private fun leaveCalendarWeekdays(): List<DayOfWeek> =
Instant.ofEpochMilli(value).atZone(ZoneOffset.UTC).toLocalDate().toString() 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 = private fun shortDateTime(value: String): String =
runCatching { 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)) Spacer(Modifier.height(if (compactWidth) 18.dp else 26.dp))
RouteTimeline(display.origin, display.destination) 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 @Composable
private fun RouteTimeline(origin: String, destination: String) { private fun RouteTimeline(origin: String, destination: String) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
@@ -1930,6 +2338,9 @@ private fun RouteStageScreen(
stage: String, stage: String,
weightLabel: String, weightLabel: String,
submitLabel: String, submitLabel: String,
showWeightInput: Boolean = true,
showPhotoActions: Boolean = true,
showPhotoGrid: Boolean = true,
onBack: () -> Unit, onBack: () -> Unit,
onWeightChange: (String) -> Unit, onWeightChange: (String) -> Unit,
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
@@ -1944,11 +2355,23 @@ private fun RouteStageScreen(
val route = state.displaySelectedRoute val route = state.displaySelectedRoute
val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty() val stagePhotos = route?.photos?.let { routePhotosForStage(it, stage) }.orEmpty()
val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val stageUploads = state.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
val submitBlocker = routeStageSubmitBlocker( val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size)
weightText = state.routeStageWeightText, val submitBlocker = when {
serverPhotoCount = stagePhotos.size, !showWeightInput -> loadingPhotosSubmitBlocker(
localUploadCount = stageUploads.size, 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 weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -2004,71 +2427,82 @@ private fun RouteStageScreen(
} }
item { item {
RouteStageChecklist( RouteStageChecklist(
hasValidWeight = routeStageSubmitBlocker( hasValidWeight = if (showWeightInput) {
weightText = state.routeStageWeightText, routeStageSubmitBlocker(
serverPhotoCount = 1, weightText = state.routeStageWeightText,
localUploadCount = 0, serverPhotoCount = 1,
) == null, localUploadCount = 0,
photoCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size), ) == null
} else {
null
},
photoCount = attachmentCount,
photoLabel = if (stage == "loading") "Zdjęcie załadunku" else "Zdjęcie etapu",
) )
} }
item { if (showWeightInput) {
OutlinedTextField( item {
value = state.routeStageWeightText, OutlinedTextField(
onValueChange = onWeightChange, value = state.routeStageWeightText,
label = { Text(weightLabel) }, onValueChange = onWeightChange,
trailingIcon = { label = { Text(weightLabel) },
Text( trailingIcon = {
"kg", Text(
color = TppTheme.colors.muted, "kg",
fontFamily = FontFamily.Monospace, color = TppTheme.colors.muted,
fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace,
) fontWeight = FontWeight.Bold,
}, )
supportingText = { },
if (weightBlocker != null) { supportingText = {
Text(weightBlocker) if (weightBlocker != null) {
} Text(weightBlocker)
}, }
isError = weightBlocker != null, },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), isError = weightBlocker != null,
singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(), singleLine = true,
) modifier = Modifier.fillMaxWidth(),
)
}
} }
item { if (showPhotoActions) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { item {
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val newUri = createCameraUri(context) CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) {
cameraUri = newUri val newUri = createCameraUri(context)
val missingPermissions = cameraCapturePermissions( cameraUri = newUri
context = context, val missingPermissions = cameraCapturePermissions(
requirePreciseLocation = state.requirePreciseLocationForPhotos, context = context,
) requirePreciseLocation = state.requirePreciseLocationForPhotos,
if (missingPermissions.isEmpty()) { )
cameraLauncher.launch(newUri) if (missingPermissions.isEmpty()) {
} else { cameraLauncher.launch(newUri)
cameraPermissionLauncher.launch(missingPermissions) } else {
cameraPermissionLauncher.launch(missingPermissions)
}
} }
} if (state.allowGalleryUploads) {
if (state.allowGalleryUploads) { CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }
} }
} }
} }
} }
item { if (showPhotoGrid) {
PhotoGrid( item {
photos = stagePhotos, PhotoGrid(
uploads = stageUploads, photos = stagePhotos,
deletingPhotoIds = state.deletingPhotoIds, uploads = stageUploads,
imageAuthHeader = state.imageAuthHeader, deletingPhotoIds = state.deletingPhotoIds,
onPhoto = onPhoto, imageAuthHeader = state.imageAuthHeader,
onDeletePhoto = {}, onPhoto = onPhoto,
onDeleteUpload = onDeleteUpload, onDeletePhoto = {},
onRetryUpload = onRetryUpload, onDeleteUpload = onDeleteUpload,
) onRetryUpload = onRetryUpload,
)
}
} }
item { ErrorText(state.error) } item { ErrorText(state.error) }
} }
@@ -2155,7 +2589,7 @@ private fun RouteStageSummaryFact(label: String, value: String, modifier: Modifi
} }
@Composable @Composable
private fun RouteStageChecklist(hasValidWeight: Boolean, photoCount: Int) { private fun RouteStageChecklist(hasValidWeight: Boolean?, photoCount: Int, photoLabel: String) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline), border = BorderStroke(1.dp, TppTheme.colors.outline),
@@ -2169,13 +2603,15 @@ private fun RouteStageChecklist(hasValidWeight: Boolean, photoCount: Int) {
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
if (hasValidWeight != null) {
RouteStageRequirementRow(
label = "Waga",
done = hasValidWeight,
detail = if (hasValidWeight) "uzupełniona" else "wpisz wartość w kg",
)
}
RouteStageRequirementRow( RouteStageRequirementRow(
label = "Waga", label = photoLabel,
done = hasValidWeight,
detail = if (hasValidWeight) "uzupełniona" else "wpisz wartość w kg",
)
RouteStageRequirementRow(
label = "Zdjęcie etapu",
done = photoCount > 0, done = photoCount > 0,
detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else "dodaj minimum jedno", detail = if (photoCount > 0) "$photoCount ${photoCountLabel(photoCount)}" else "dodaj minimum jedno",
) )
@@ -2355,9 +2791,19 @@ private fun RouteLifecycleSection(
val steps = routeFlowSteps(route, routeActions) val steps = routeFlowSteps(route, routeActions)
val callout = routeSyncCallout(routeActions) val callout = routeSyncCallout(routeActions)
val action = routeLifecyclePrimaryAction(route, selectedDate) val action = routeLifecyclePrimaryAction(route, selectedDate)
val instruction = routeTodayInstruction(route, selectedDate)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
RouteFlowStepper(steps) 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()) { if (!feedback.isNullOrBlank()) {
RouteStateMessage( RouteStateMessage(
text = feedback, text = feedback,
@@ -2430,9 +2876,16 @@ private fun RouteFlowStepper(steps: List<RouteFlowStepUi>) {
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
steps.forEach { step -> steps.chunked(2).forEach { rowSteps ->
RouteFlowStepItem(step, Modifier.weight(1f)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowSteps.forEach { step ->
RouteFlowStepItem(step, Modifier.weight(1f))
}
if (rowSteps.size == 1) {
Spacer(Modifier.weight(1f))
}
}
} }
} }
} }
@@ -2459,6 +2912,15 @@ private fun RouteFlowStepItem(step: RouteFlowStepUi, modifier: Modifier = Modifi
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, 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( Text(
step.stateLabel, step.stateLabel,
color = color, color = color,
@@ -2539,6 +3001,9 @@ private fun photoCountLabel(count: Int): String =
else -> "zdjęć" else -> "zdjęć"
} }
private fun leaveConflictCountLabel(count: Int): String =
if (count == 1) "1 urlopem" else "$count urlopami"
@Composable @Composable
private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) { private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status) val stripColor = routeStatusColor(route.status)
@@ -2567,6 +3032,7 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = TppTheme.colors.ink, color = TppTheme.colors.ink,
) )
RouteScheduleLabel(route)
} }
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.65f)) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.65f))
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
data class LeaveRequestsConfig( data class LeaveRequestsConfig(
val enabled: Boolean = false, val enabled: Boolean = false,
@@ -52,4 +53,41 @@ object DriverLeaveRequestUiRules {
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today) 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"
}
} }
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -21,14 +22,18 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent, RouteActionStatus.FailedPermanent,
) )
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute { fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
var projectedRoute = route var projectedRoute = route
var loadingWeightPending = false var loadingWeightPending = false
var unloadingWeightPending = false var unloadingWeightPending = false
visible projectable
.sortedWith(compareBy<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId }) .sortedWith(compareBy<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId })
.forEach { action -> .forEach { action ->
when (action.action) { when (action.action) {
@@ -37,8 +42,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
status = "W TRAKCIE", status = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
trackingStatus = "active", trackingStatus = "active",
photos = projectedRoute.safePhotos(),
) )
loadingWeightPending = true loadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
@@ -46,8 +52,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
unloadingWeight = action.weight, unloadingWeight = action.weight,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = action.occurredAt, completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
) )
unloadingWeightPending = true unloadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
} }
} }
@@ -67,3 +74,5 @@ fun projectDriverRoutes(routes: List<DriverRouteDto>, actions: List<RouteActionE
fun routeActionNeedsAttention(status: String): Boolean = fun routeActionNeedsAttention(status: String): Boolean =
status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent
internal fun DriverRouteDto.safePhotos(): List<RoutePhotoDto> = photos.orEmpty()
@@ -34,6 +34,7 @@ data class RouteFlowStepUi(
val title: String, val title: String,
val state: RouteFlowStepState, val state: RouteFlowStepState,
val stateLabel: String, val stateLabel: String,
val dateLabel: String? = null,
) )
data class RouteLifecycleActionUi( data class RouteLifecycleActionUi(
@@ -49,12 +50,68 @@ data class RouteSyncCalloutUi(
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false) 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( fun canCompleteRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): 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") && route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp( fun canStartRouteFromDriverApp(
@@ -62,7 +119,7 @@ fun canStartRouteFromDriverApp(
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
canManageRoutePhotos(selectedDate, today) routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA" && route.status == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp( fun canFinishRouteFromDriverApp(
@@ -70,13 +127,34 @@ fun canFinishRouteFromDriverApp(
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): 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 { fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUploadCount: Int): Boolean {
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null 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? { fun routeStageSubmitBlocker(weightText: String, serverPhotoCount: Int, localUploadCount: Int): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull()
@@ -96,8 +174,8 @@ fun routeLifecyclePrimaryAction(
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): RouteLifecycleActionUi? = ): RouteLifecycleActionUi? =
when { when {
canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozpocznij kurs", RouteLifecycleAction.Start) canStartRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Załaduj", RouteLifecycleAction.Start)
canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Zakończ kurs", RouteLifecycleAction.Finish) canFinishRouteFromDriverApp(route, selectedDate, today) -> RouteLifecycleActionUi("Rozładuj", RouteLifecycleAction.Finish)
else -> null else -> null
} }
@@ -114,8 +192,10 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
} else { } else {
when (unresolved.status) { when (unresolved.status) {
RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu." 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." 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, isConflict = isConflict,
@@ -125,7 +205,15 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): List<RouteFlowStepUi> { fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): List<RouteFlowStepUi> {
val startOverride = routeActionStepState(actions, RouteActionType.Start) val startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish) 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 == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete route.loadingWeight != null -> RouteFlowStepState.LocalComplete
@@ -145,15 +233,16 @@ fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): Lis
} }
return listOf( 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("transit", "W trasie", transitState, routeFlowStateLabel(transitState)),
RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState)), RouteFlowStepUi("unloading", "Rozładunek", unloadingState, routeFlowStateLabel(unloadingState), routeScheduleDateLabel(routeUnloadingDate(route))),
) )
} }
fun routePhotosForStage(photos: List<RoutePhotoDto>, stage: String): List<RoutePhotoDto> { fun routePhotosForStage(photos: List<RoutePhotoDto>?, stage: String): List<RoutePhotoDto> {
val normalized = normalizedRoutePhotoStage(stage) val normalized = normalizedRoutePhotoStage(stage)
return photos.filter { normalizedRoutePhotoStage(it.stage) == normalized } return photos.orEmpty().filter { normalizedRoutePhotoStage(it.stage) == normalized }
} }
fun normalizedRoutePhotoStage(stage: String?): String = fun normalizedRoutePhotoStage(stage: String?): String =
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate import java.time.LocalDate
import java.time.Instant import java.time.Instant
import pl.firmatpp.kierowca.data.AppPreferencesStore 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.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto 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.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto 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.tracking.ActiveRouteTrackingService
import pl.firmatpp.kierowca.ui.theme.AppThemeMode 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( data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
@@ -74,6 +76,11 @@ data class DriverUiState(
val leaveRequestDateTo: String = LocalDate.now().toString(), val leaveRequestDateTo: String = LocalDate.now().toString(),
val leaveRequestType: String = "URLOP", val leaveRequestType: String = "URLOP",
val leaveRequestNote: String = "", 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 deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false, val completingRoute: Boolean = false,
val imageAuthHeader: String? = null, val imageAuthHeader: String? = null,
@@ -90,6 +97,10 @@ data class DriverUiState(
val displayRoutes: List<DriverRouteDto> val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions) get() = projectDriverRoutes(routes, projectionActions)
.sortedWith(
compareBy<DriverRouteDto> { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 }
.thenBy { route -> routeDayOrdering(route, selectedDate) },
)
val selectedRouteProjection: ProjectedDriverRoute? val selectedRouteProjection: ProjectedDriverRoute?
get() = selectedRoute?.let { projectDriverRoute(it, projectionActions) } get() = selectedRoute?.let { projectDriverRoute(it, projectionActions) }
@@ -111,12 +122,17 @@ internal fun DriverUiState.withLoadedLeaveRequests(
requests: List<DriverLeaveRequestDto>, requests: List<DriverLeaveRequestDto>,
navigateToList: Boolean, navigateToList: Boolean,
): DriverUiState { ): 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 -> 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( return copy(
screen = if (navigateToList) DriverScreen.LeaveRequests else screen, screen = if (navigateToList) DriverScreen.LeaveRequests else screen,
leaveRequests = requests, leaveRequests = merged,
selectedLeaveRequest = refreshedSelectedRequest, selectedLeaveRequest = refreshedSelectedRequest,
error = null, error = null,
) )
@@ -141,6 +157,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var routeActionsJob: Job? = null private var routeActionsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = 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 val state: StateFlow<DriverUiState> = _state
init { init {
@@ -311,7 +330,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
screen = targetScreen, screen = targetScreen,
selectedRoute = response.route, selectedRoute = response.route,
routeStageWeightText = when (targetScreen) { routeStageWeightText = when (targetScreen) {
DriverScreen.StartRoute -> response.route.loadingWeight?.toString().orEmpty() DriverScreen.LoadingWeight -> response.route.loadingWeight?.toString().orEmpty()
DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty() DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty()
else -> it.routeStageWeightText else -> it.routeStageWeightText
}, },
@@ -409,24 +428,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openLeaveRequests() { fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true, navigateToList = true) viewModelScope.launch { loadLeaveRequests(showLoading = true, navigateToList = true) }
} }
fun refreshLeaveRequests() { fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return 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) { private suspend fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean): Boolean {
viewModelScope.launch { _state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) } val result = runCatching { repository.leaveRequests() }
runCatching { repository.leaveRequests() } result.onSuccess { requests ->
.onSuccess { requests -> _state.update { it.withLoadedLeaveRequests(requests, navigateToList) }
_state.update { it.withLoadedLeaveRequests(requests, navigateToList) } }.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
} _state.update { it.copy(loading = false, refreshing = false) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } } return result.isSuccess
_state.update { it.copy(loading = false, refreshing = false) }
}
} }
fun openLeaveRequest(id: String) { fun openLeaveRequest(id: String) {
@@ -448,15 +465,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val today = LocalDate.now().toString() val today = LocalDate.now().toString()
_state.update { _state.update {
it.copy( it.copy(
screen = DriverScreen.AddLeaveRequest, screen = DriverScreen.LeaveCalendar,
leaveRequestDateFrom = today, leaveRequestDateFrom = today,
leaveRequestDateTo = today, leaveRequestDateTo = today,
leaveRequestType = config?.types?.firstOrNull() ?: "URLOP", leaveRequestType = config?.types?.firstOrNull() ?: "URLOP",
leaveRequestNote = "", leaveRequestNote = "",
leaveCalendarEntries = emptyList(),
leaveCalendarLoadedUntil = null,
leaveCalendarHasSelection = false,
leaveCalendarSelectionAnchor = null,
error = null, error = null,
feedback = null, feedback = null,
) )
} }
loadMoreLeaveCalendarMonths(reset = true)
} }
fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) { 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() { fun submitLeaveRequest() {
val snapshot = _state.value val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
if (!leaveMutationMutex.tryLock()) return
if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) { if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) {
_state.update { it.copy(error = "Data od nie może być z przeszłości.") } _state.update { it.copy(error = "Data od nie może być z przeszłości.") }
leaveMutationMutex.unlock()
return return
} }
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) } _state.update { it.copy(loading = true, error = null, feedback = null) }
try {
runCatching { runCatching {
repository.createLeaveRequest( repository.createLeaveRequest(
dateFrom = snapshot.leaveRequestDateFrom, dateFrom = snapshot.leaveRequestDateFrom,
@@ -504,16 +625,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.withApiError(throwable) } _state.update { it.withApiError(throwable) }
} }
_state.update { it.copy(loading = false) } _state.update { it.copy(loading = false) }
} finally {
leaveMutationMutex.unlock()
consumePendingLeaveHint()
}
} }
} }
fun cancelSelectedLeaveRequest() { fun cancelSelectedLeaveRequest() {
val request = _state.value.selectedLeaveRequest ?: return val request = _state.value.selectedLeaveRequest ?: return
if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return
if (!leaveMutationMutex.tryLock()) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) } _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 -> .onSuccess { updated ->
_state.update { _state.update {
it.copy( 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) } _state.update { it.copy(loading = false) }
} finally {
leaveMutationMutex.unlock()
consumePendingLeaveHint()
}
} }
} }
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata(), stage: String = "other") { 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 { viewModelScope.launch {
_state.update { it.copy(error = null) } _state.update { it.copy(error = null) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) } runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata, stage) }
@@ -541,10 +691,39 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
fun openStartRoute() { 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 { _state.update {
it.copy( it.copy(
screen = DriverScreen.StartRoute, 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(), routeStageWeightText = route.loadingWeight?.toString().orEmpty(),
feedback = null, feedback = null,
error = null, error = null,
@@ -553,7 +732,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
fun openFinishRoute() { 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 { _state.update {
it.copy( it.copy(
screen = DriverScreen.FinishRoute, screen = DriverScreen.FinishRoute,
@@ -580,20 +764,41 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun submitRouteStage(stage: String) { private fun submitRouteStage(stage: String) {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return 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 driver = snapshot.driver
val weight = snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() val weight = snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull()
val stagePhotos = routePhotosForStage(route.photos, stage) val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }
val submitBlocker = if (stage == "loading") {
if ( loadingWeightSubmitBlocker(
weight == null ||
!canSubmitRouteStageForm(
weightText = snapshot.routeStageWeightText, weightText = snapshot.routeStageWeightText,
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = stageUploads.size,
) )
) { } else {
_state.update { it.copy(error = "Podaj wagę i dodaj co najmniej jedno zdjęcie.") } 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 return
} }
@@ -617,6 +822,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
status = "W TRAKCIE", status = "W TRAKCIE",
loadingWeight = weight, loadingWeight = weight,
trackingStatus = "active", trackingStatus = "active",
photos = route.safePhotos(),
) )
} }
"unloading" -> { "unloading" -> {
@@ -626,6 +832,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
unloadingWeight = weight, unloadingWeight = weight,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = Instant.now().toString(), completedAt = Instant.now().toString(),
photos = route.safePhotos(),
) )
} }
else -> route else -> route
@@ -744,8 +951,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
fun completeSelectedRoute() { fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return val snapshot = _state.value
if (_state.value.completingRoute || !_state.value.isOnline) return 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 { viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) } _state.update { it.copy(completingRoute = true, feedback = null, error = null) }
@@ -790,9 +1002,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) 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.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes) DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) 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.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
@@ -884,12 +1098,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value val snapshot = _state.value
val routeId = snapshot.selectedRoute?.id val routeId = snapshot.selectedRoute?.id
val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return 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) { if (staleScopes.isEmpty()) {
refreshCurrentScopeFromSyncState()
} else {
syncRepository.saveSyncStates(remote) syncRepository.saveSyncStates(remote)
return
}
staleScopes.forEach { scope ->
handleSyncHint(
DriverSyncHint(
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
version = scope.version,
checksum = scope.checksum,
),
)
} }
} }
@@ -920,13 +1145,58 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently() DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> { 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) { 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) }
}
}
private fun registerPushTokenIfAvailable(driverId: String) { private fun registerPushTokenIfAvailable(driverId: String) {
if (pushTokenRegisteredForDriverId == driverId) return if (pushTokenRegisteredForDriverId == driverId) return
@@ -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") }) 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 @Test
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest { fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
class DriverLeaveRequestUiRulesTest { class DriverLeaveRequestUiRulesTest {
@Test @Test
@@ -48,4 +50,59 @@ class DriverLeaveRequestUiRulesTest {
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday)) assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow)) 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",
),
)
} }
@@ -1,11 +1,13 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import com.google.gson.Gson
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -57,6 +59,54 @@ class DriverRouteProjectionTest {
assertEquals("finished", projection.route.trackingStatus) assertEquals("finished", projection.route.trackingStatus)
} }
@Test
fun confirmedFinishStillProjectsPlannedRouteAsFinishedWithoutSyncCallout() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(
action(RouteActionType.Finish, RouteActionStatus.Confirmed, weight = 11.8, createdAt = 2000),
),
)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("finished", projection.route.trackingStatus)
assertFalse(projection.unloadingWeightPending)
assertEquals(emptyList<RouteActionEntity>(), projection.visibleActions)
}
@Test
fun routeProjectionNormalizesNullPhotosFromCachedJsonBeforeCopy() {
val route = Gson().fromJson(
"""
{
"id": "1",
"startsAt": "",
"originName": "Baza",
"destinationName": "Instalacja",
"contractorName": "TPP",
"contractName": "Kontrakt",
"contractCode": "TPP-1",
"relationLabel": "Baza -> Instalacja",
"status": "ZAPLANOWANA",
"distanceKm": 1.0,
"notes": null,
"truck": null,
"photos": null
}
""".trimIndent(),
DriverRouteDto::class.java,
)
val projection = projectDriverRoute(
route = route,
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Confirmed, weight = 12.5)),
)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(emptyList<RoutePhotoDto>(), projection.route.photos)
}
@Test @Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
@@ -68,7 +118,7 @@ class DriverRouteProjectionTest {
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight) 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) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false) assertTrue(callout?.isConflict ?: false)
} }
@@ -83,7 +133,7 @@ class DriverRouteProjectionTest {
assertEquals("W TRAKCIE", projection.route.status) assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertEquals(RouteFlowStepState.NeedsAttention, steps[0].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
} }
@Test @Test
@@ -104,6 +154,7 @@ class DriverRouteProjectionTest {
): DriverRouteDto = ): DriverRouteDto =
DriverRouteDto( DriverRouteDto(
id = "1", id = "1",
routeDate = "2026-06-30",
startsAt = "", startsAt = "",
originName = "Baza", originName = "Baza",
destinationName = "Instalacja", destinationName = "Instalacja",
@@ -39,6 +39,32 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today)) 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 @Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() { fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12")) assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
@@ -295,14 +321,27 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route, emptyList()) val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) 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[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state) assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].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) 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 @Test
fun activeRouteShowsTransitStateAndFinishAction() { fun activeRouteShowsTransitStateAndFinishAction() {
val route = route(status = "W TRAKCIE", loadingWeight = 12.5) val route = route(status = "W TRAKCIE", loadingWeight = 12.5)
@@ -311,9 +350,10 @@ class DriverUiRulesTest {
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today) val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(RouteFlowStepState.Confirmed, steps[0].state) assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[1].state) assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state) assertEquals(RouteFlowStepState.LocalComplete, steps[2].state)
assertEquals("Zakończ kurs", action?.label) assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action) assertEquals(RouteLifecycleAction.Finish, action?.action)
} }
@@ -327,6 +367,7 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.Confirmed, steps[0].state) assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state) assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.Confirmed, steps[2].state) assertEquals(RouteFlowStepState.Confirmed, steps[2].state)
assertEquals(RouteFlowStepState.Confirmed, steps[3].state)
assertEquals(null, action) assertEquals(null, action)
} }
@@ -337,7 +378,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions) val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(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) assertEquals("Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu.", callout?.text)
assertFalse(callout?.isConflict ?: true) assertFalse(callout?.isConflict ?: true)
} }
@@ -349,7 +390,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions) val steps = routeFlowSteps(route(status = "W TRAKCIE", loadingWeight = 12.5), actions)
val callout = routeSyncCallout(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) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false) assertTrue(callout?.isConflict ?: false)
} }
@@ -362,14 +403,40 @@ class DriverUiRulesTest {
assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1)) 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( private fun route(
status: String, status: String = "ZAPLANOWANA",
loadingWeight: Double? = null, loadingWeight: Double? = null,
unloadingWeight: Double? = null, unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(), photos: List<RoutePhotoDto> = emptyList(),
routeDate: String? = "2026-06-30",
unloadingDate: String? = null,
): DriverRouteDto = ): DriverRouteDto =
DriverRouteDto( DriverRouteDto(
id = "1", id = "1",
routeDate = routeDate,
unloadingDate = unloadingDate,
startsAt = "", startsAt = "",
originName = "Baza", originName = "Baza",
destinationName = "Instalacja", destinationName = "Instalacja",
@@ -55,6 +55,23 @@ class DriverUiStateTest {
assertEquals(1, refreshed.leaveRequests.size) 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 @Test
fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() { fun displayRoutesAndSelectedRouteUseLocalRouteActionsProjection() {
val route = route(status = "ZAPLANOWANA") val route = route(status = "ZAPLANOWANA")
@@ -70,18 +87,54 @@ class DriverUiStateTest {
assertEquals(12.5, state.displaySelectedRoute?.loadingWeight) 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( DriverLeaveRequestDto(
id = id, id = id,
dateFrom = "2026-07-10", dateFrom = "2026-07-10",
dateTo = "2026-07-12", dateTo = "2026-07-12",
type = "URLOP", type = "URLOP",
status = status, 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( DriverRouteDto(
id = "1", id = id,
routeDate = "2026-06-30",
ordering = ordering,
dayOrderings = dayOrderings,
startsAt = "", startsAt = "",
originName = "Baza", originName = "Baza",
destinationName = "Instalacja", destinationName = "Instalacja",