Compare commits

...
Author SHA1 Message Date
admin 8a0651e578 Rozdziel załadunek i dodaj kalendarz urlopów 2026-07-09 00:05:57 +02:00
admin 32d45f6430 Bump driver app version to 51 2026-07-08 02:03:33 +02:00
admin 4a06e9da8b Retry route actions after photo upload 2026-07-08 02:01:37 +02:00
16 changed files with 916 additions and 169 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 = 50 versionCode = 101
versionName = "1.0.44" versionName = "1.0.48"
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,6 +62,9 @@ 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
@@ -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,
) )
@@ -206,6 +210,22 @@ data class DriverLeaveRequestDto(
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,
@@ -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,6 +14,9 @@ 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
@@ -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)
}
@@ -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,16 +262,35 @@ 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 = "Rozpocznij kurs",
showWeightInput = true,
showPhotoActions = false,
showPhotoGrid = false,
onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { _, _, _ -> },
onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload,
onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitStartRoute, onSubmit = viewModel::submitStartRoute,
) )
DriverScreen.FinishRoute -> RouteStageScreen( DriverScreen.FinishRoute -> RouteStageScreen(
@@ -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,344 @@ private fun LeaveRequestDetailScreen(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LeaveCalendarScreen(
state: DriverUiState,
onBack: () -> Unit,
onDateSelected: (String) -> Unit,
onContinue: () -> Unit,
onLoadMore: () -> Unit,
) {
val context = LocalContext.current
val listState = rememberLazyListState()
val months = remember(state.leaveCalendarLoadedUntil) { leaveCalendarMonths(state.leaveCalendarLoadedUntil) }
var detailsDate by remember { mutableStateOf<LocalDate?>(null) }
val detailsEntries = detailsDate
?.let { DriverLeaveRequestUiRules.calendarEntriesForDate(state.leaveCalendarEntries, it) }
.orEmpty()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
LaunchedEffect(months.size, state.leaveCalendarLoading) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 }
.distinctUntilChanged()
.collect { lastVisibleIndex ->
if (state.leaveCalendarLoadedUntil != null && !state.leaveCalendarLoading && lastVisibleIndex >= months.size) {
onLoadMore()
}
}
}
Scaffold(
topBar = { SimpleTopBar("Kalendarz urlopów", onBack) },
bottomBar = {
LeaveCalendarContinueBar(
state = state,
onContinue = onContinue,
)
},
containerColor = TppTheme.colors.surface,
) { padding ->
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 120.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { FeedbackAndError(null, state.error) }
items(months, key = { it.toString() }) { month ->
LeaveCalendarMonth(
month = month,
entries = state.leaveCalendarEntries,
selectedFrom = state.leaveRequestDateFrom,
selectedTo = state.leaveRequestDateTo,
hasSelection = state.leaveCalendarHasSelection,
onDayClick = { date, entries ->
onDateSelected(date.toString())
if (entries.isNotEmpty()) {
detailsDate = date
}
},
)
}
item {
if (state.leaveCalendarLoading) {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(color = TppTheme.colors.forest, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(10.dp))
Text("Pobieram kolejne miesiące", color = TppTheme.colors.muted)
}
} else {
TextButton(onClick = onLoadMore, modifier = Modifier.fillMaxWidth()) {
Text("Pokaż kolejne miesiące", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold)
}
}
}
}
}
if (detailsDate != null && detailsEntries.isNotEmpty()) {
ModalBottomSheet(
onDismissRequest = { detailsDate = null },
sheetState = sheetState,
containerColor = TppTheme.colors.card,
) {
LeaveCalendarDetailsSheet(
date = detailsDate,
entries = detailsEntries,
onCall = { dialUri ->
context.startActivity(Intent(Intent.ACTION_DIAL, Uri.parse(dialUri)))
},
)
}
}
}
@Composable
private fun LeaveCalendarContinueBar(state: DriverUiState, onContinue: () -> Unit) {
val selectionText = when {
!state.leaveCalendarHasSelection -> "Wybierz pierwszy dzień urlopu"
else -> leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo)
}
Column(
Modifier
.fillMaxWidth()
.background(Color.White)
.border(0.5.dp, TppTheme.colors.outline.copy(alpha = 0.65f))
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("Wybrany termin", color = TppTheme.colors.muted, fontSize = 12.sp, fontWeight = FontWeight.Bold)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
selectionText,
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Button(
onClick = onContinue,
enabled = state.leaveCalendarHasSelection,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.height(48.dp),
) {
Text("Dalej", fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun LeaveCalendarMonth(
month: YearMonth,
entries: List<DriverLeaveCalendarEntryDto>,
selectedFrom: String,
selectedTo: String,
hasSelection: Boolean,
onDayClick: (LocalDate, List<DriverLeaveCalendarEntryDto>) -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
leaveCalendarMonthTitle(month),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
leaveCalendarWeekdays().forEach { day ->
Text(
leaveCalendarWeekdayLabel(day),
color = TppTheme.colors.muted,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.weight(1f),
)
}
}
monthDaysGrid(month).chunked(7).forEach { week ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
week.forEach { date ->
val dayEntries = date
?.let { DriverLeaveRequestUiRules.calendarEntriesForDate(entries, it) }
.orEmpty()
LeaveCalendarDayCell(
date = date,
entries = dayEntries,
selected = date?.let { leaveCalendarDateInRange(it, selectedFrom, selectedTo, hasSelection) } == true,
today = date == LocalDate.now(),
enabled = date != null && !date.isBefore(LocalDate.now()),
onClick = {
if (date != null) {
onDayClick(date, dayEntries)
}
},
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
@Composable
private fun LeaveCalendarDayCell(
date: LocalDate?,
entries: List<DriverLeaveCalendarEntryDto>,
selected: Boolean,
today: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasConflicts = entries.isNotEmpty()
val background = when {
selected -> TppTheme.colors.successContainer
hasConflicts -> TppTheme.colors.warningContainer
today -> Color.White
else -> TppTheme.colors.panel
}
val outline = when {
selected -> TppTheme.colors.forest
hasConflicts -> TppTheme.colors.warningOutline
today -> TppTheme.colors.forest.copy(alpha = 0.45f)
else -> TppTheme.colors.outline.copy(alpha = 0.45f)
}
val textColor = when {
!enabled -> TppTheme.colors.muted.copy(alpha = 0.45f)
selected -> TppTheme.colors.forest
hasConflicts -> Color(0xFF7A5A00)
else -> TppTheme.colors.ink
}
Box(
modifier
.aspectRatio(1f)
.background(background, RoundedCornerShape(4.dp))
.border(1.dp, outline, RoundedCornerShape(4.dp))
.clickable(enabled = enabled, onClick = onClick)
.padding(4.dp),
) {
if (date != null) {
Text(
date.dayOfMonth.toString(),
color = textColor,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier.align(Alignment.TopStart),
)
if (hasConflicts) {
Text(
entries.size.toString(),
color = if (selected) Color.White else Color(0xFF7A5A00),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
modifier = Modifier
.align(Alignment.BottomEnd)
.background(if (selected) TppTheme.colors.forest else Color(0xFFFFE08A), RoundedCornerShape(999.dp))
.padding(horizontal = 5.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun LeaveCalendarDetailsSheet(
date: LocalDate?,
entries: List<DriverLeaveCalendarEntryDto>,
onCall: (String) -> Unit,
) {
Column(
Modifier.fillMaxWidth().navigationBarsPadding().padding(start = 20.dp, end = 20.dp, bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
date?.let { DateTimeFormatter.ofPattern("d MMMM yyyy", Locale("pl", "PL")).format(it) } ?: "Urlopy w tym dniu",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
entries.forEach { entry ->
LeaveCalendarDetailsEntry(entry = entry, onCall = onCall)
}
}
}
@Composable
private fun LeaveCalendarDetailsEntry(entry: DriverLeaveCalendarEntryDto, onCall: (String) -> Unit) {
val dialUri = DriverLeaveRequestUiRules.phoneDialUri(entry.driver.phoneNumber)
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppTheme.colors.outline),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
entry.driver.displayName?.takeIf { it.isNotBlank() } ?: "Kierowca #${entry.driver.id}",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
)
Text(
leaveDateRange(entry.dateFrom, entry.dateTo),
color = TppTheme.colors.muted,
fontWeight = FontWeight.SemiBold,
)
}
LeaveStatusPill(entry.status)
}
DetailLine("Typ", entry.typeLabel?.takeIf { it.isNotBlank() } ?: DriverLeaveRequestUiRules.typeLabel(entry.type))
DetailLine("Telefon", entry.driver.phoneNumber?.takeIf { it.isNotBlank() } ?: "Brak numeru telefonu")
if (dialUri != null) {
Button(
onClick = { onCall(dialUri) },
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().height(50.dp),
) {
Icon(Icons.Outlined.Phone, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Dzwoń", fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable @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 +1412,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 +1456,6 @@ private fun AddLeaveRequestScreen(
} }
} }
} }
if (showDateRangePicker) {
DatePickerDialog(
onDismissRequest = { showDateRangePicker = false },
confirmButton = {
TextButton(
onClick = {
val start = dateRangePickerState.selectedStartDateMillis
val end = dateRangePickerState.selectedEndDateMillis ?: start
if (start != null && end != null) {
onDraft(utcMillisToLocalDateString(start), utcMillisToLocalDateString(end), null, null)
}
showDateRangePicker = false
},
enabled = dateRangePickerState.selectedStartDateMillis != null,
) {
Text("Ustaw", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDateRangePicker = false }) {
Text("Anuluj", color = TppTheme.colors.muted)
}
},
) {
DateRangePicker(
state = dateRangePickerState,
title = {
Text(
"Wybierz okres urlopu",
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
headline = {
Text(
leaveDateRange(
dateRangePickerState.selectedStartDateMillis?.let(::utcMillisToLocalDateString),
dateRangePickerState.selectedEndDateMillis?.let(::utcMillisToLocalDateString),
),
modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 12.dp),
color = TppTheme.colors.muted,
)
},
showModeToggle = false,
)
}
}
} }
@Composable @Composable
@@ -1227,13 +1527,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 {
@@ -1930,6 +2282,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 +2299,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 +2371,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 +2533,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 +2547,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",
) )
@@ -2430,9 +2810,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))
}
}
} }
} }
} }
@@ -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,28 @@ 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 phoneDialUri(phoneNumber: String?): String? {
val raw = phoneNumber?.trim().orEmpty()
if (raw.isBlank()) return null
val digits = raw.filter { it.isDigit() }
if (digits.isBlank()) return null
val normalized = when {
digits.startsWith("00") -> "+${digits.drop(2)}"
raw.startsWith("+") -> "+$digits"
digits.length == 9 -> "+48$digits"
else -> "+$digits"
}
return "tel:$normalized"
}
} }
@@ -77,6 +77,26 @@ fun canSubmitRouteStageForm(weightText: String, serverPhotoCount: Int, localUplo
return routeStageSubmitBlocker(weightText, serverPhotoCount, localUploadCount) == null 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()
@@ -114,8 +134,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 +147,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,7 +175,8 @@ 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)),
RouteFlowStepUi("loading_weight", "Waga załadunku", loadingWeightState, routeFlowStateLabel(loadingWeightState)),
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)),
) )
@@ -21,6 +21,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 +38,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 +75,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,
@@ -311,7 +317,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
}, },
@@ -448,15 +454,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,6 +483,99 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun selectLeaveCalendarDate(date: String) {
val selected = runCatching { LocalDate.parse(date) }.getOrNull() ?: return
if (selected.isBefore(LocalDate.now())) return
_state.update {
val anchor = it.leaveCalendarSelectionAnchor
if (!it.leaveCalendarHasSelection || anchor == null) {
it.copy(
leaveRequestDateFrom = date,
leaveRequestDateTo = date,
leaveCalendarHasSelection = true,
leaveCalendarSelectionAnchor = date,
error = null,
)
} else {
val start = LocalDate.parse(anchor)
val from = minOf(start, selected).toString()
val to = maxOf(start, selected).toString()
it.copy(
leaveRequestDateFrom = from,
leaveRequestDateTo = to,
leaveCalendarHasSelection = true,
leaveCalendarSelectionAnchor = null,
error = null,
)
}
}
}
fun continueAddLeaveRequestFromCalendar() {
if (!_state.value.leaveCalendarHasSelection) {
_state.update { it.copy(error = "Wybierz termin urlopu w kalendarzu.") }
return
}
_state.update { it.copy(screen = DriverScreen.AddLeaveRequest, error = null, feedback = null) }
}
fun reopenLeaveCalendarFromDraft() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
_state.update {
it.copy(
screen = DriverScreen.LeaveCalendar,
error = null,
feedback = null,
)
}
if (_state.value.leaveCalendarLoadedUntil == null) {
loadMoreLeaveCalendarMonths(reset = true)
}
}
fun loadMoreLeaveCalendarMonths(reset: Boolean = false) {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig) || snapshot.leaveCalendarLoading) return
val from = if (reset || snapshot.leaveCalendarLoadedUntil == null) {
LocalDate.now()
} else {
runCatching { LocalDate.parse(snapshot.leaveCalendarLoadedUntil).plusDays(1) }.getOrDefault(LocalDate.now())
}
val to = from.plusMonths(6).minusDays(1)
viewModelScope.launch {
_state.update {
it.copy(
leaveCalendarLoading = true,
error = null,
leaveCalendarEntries = if (reset) emptyList() else it.leaveCalendarEntries,
leaveCalendarLoadedUntil = if (reset) null else it.leaveCalendarLoadedUntil,
)
}
runCatching { repository.leaveCalendar(from.toString(), to.toString()) }
.onSuccess { entries ->
_state.update {
val merged = (it.leaveCalendarEntries + entries)
.distinctBy { entry -> entry.id }
.sortedWith(compareBy<DriverLeaveCalendarEntryDto> { entry -> entry.dateFrom.orEmpty() }.thenBy { entry -> entry.id })
it.copy(
leaveCalendarEntries = merged,
leaveCalendarLoadedUntil = to.toString(),
leaveCalendarLoading = false,
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.withApiError(throwable).copy(leaveCalendarLoading = false) }
}
}
}
fun submitLeaveRequest() { fun submitLeaveRequest() {
val snapshot = _state.value val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
@@ -545,6 +649,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_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,
@@ -584,16 +712,22 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
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
} }
@@ -792,9 +926,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()
@@ -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,
)
}
@@ -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,40 @@ 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 normalizesPhoneNumberIntoDialerUri() {
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("+48 600 700 800"))
assertEquals("tel:+48600700800", DriverLeaveRequestUiRules.phoneDialUri("0048 600 700 800"))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(null))
assertEquals(null, DriverLeaveRequestUiRules.phoneDialUri(" "))
}
private fun calendarEntry(id: String, dateFrom: String, dateTo: String): DriverLeaveCalendarEntryDto =
DriverLeaveCalendarEntryDto(
id = id,
dateFrom = dateFrom,
dateTo = dateTo,
type = "URLOP",
typeLabel = "Urlop",
status = "approved",
driver = DriverLeaveCalendarDriverDto(
id = "driver-$id",
displayName = "Kierowca $id",
phoneNumber = "+48 600 700 800",
),
)
} }
@@ -118,7 +118,7 @@ class DriverRouteProjectionTest {
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("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)
} }
@@ -133,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
@@ -295,14 +295,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(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozpocznij kurs", action?.label) assertEquals("Rozpocznij kurs", 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,8 +324,9 @@ 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(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Zakończ kurs", action?.label) assertEquals("Zakończ kurs", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action) assertEquals(RouteLifecycleAction.Finish, action?.action)
} }
@@ -327,6 +341,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 +352,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 +364,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,6 +377,28 @@ 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,
loadingWeight: Double? = null, loadingWeight: Double? = null,