Compare commits

..
2 Commits
Author SHA1 Message Date
admin e0115e85fc Pin live update toast over routes 2026-07-02 16:29:49 +02:00
admin 0e15d5f9a4 Show live day updates and route date chip 2026-07-02 16:24:06 +02:00
5 changed files with 166 additions and 19 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 35 versionCode = 37
versionName = "1.0.34" versionName = "1.0.36"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -203,6 +203,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
onProfile = viewModel::openProfile, onProfile = viewModel::openProfile,
onPhotoQueue = viewModel::openPhotoQueue, onPhotoQueue = viewModel::openPhotoQueue,
onRoute = viewModel::openRoute, onRoute = viewModel::openRoute,
onDismissLiveUpdate = viewModel::dismissRouteDayLiveUpdate,
) )
DriverScreen.Profile -> ProfileScreen( DriverScreen.Profile -> ProfileScreen(
state = state, state = state,
@@ -546,7 +547,15 @@ private fun RoutesScreen(
onProfile: () -> Unit, onProfile: () -> Unit,
onPhotoQueue: () -> Unit, onPhotoQueue: () -> Unit,
onRoute: (String) -> Unit, onRoute: (String) -> Unit,
onDismissLiveUpdate: () -> Unit,
) { ) {
LaunchedEffect(state.routeDayLiveUpdateMessage) {
if (!state.routeDayLiveUpdateMessage.isNullOrBlank()) {
delay(6_000)
onDismissLiveUpdate()
}
}
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
val screenWidthDp = maxWidth.value.toInt() val screenWidthDp = maxWidth.value.toInt()
val screenHeightDp = maxHeight.value.toInt() val screenHeightDp = maxHeight.value.toInt()
@@ -554,7 +563,17 @@ private fun RoutesScreen(
val compactWidth = screenWidthDp < 360 val compactWidth = screenWidthDp < 360
Scaffold( Scaffold(
topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) }, topBar = {
Box(Modifier.fillMaxWidth()) {
StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp)
RouteDayLiveUpdateBanner(
message = state.routeDayLiveUpdateMessage,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = horizontalPadding, vertical = 10.dp),
)
}
},
bottomBar = { bottomBar = {
StitchBottomBar( StitchBottomBar(
activeScreen = DriverScreen.Routes, activeScreen = DriverScreen.Routes,
@@ -628,6 +647,37 @@ private fun RoutesScreen(
} }
} }
@Composable
private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modifier) {
if (message.isNullOrBlank()) return
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFEAF7EF)),
border = BorderStroke(1.dp, Color(0xFF9BD1AD)),
shape = RoundedCornerShape(8.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(40.dp).background(Color.White, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Refresh, contentDescription = null, tint = TppColors.Forest)
}
Text(
message,
color = TppColors.Forest,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable @Composable
private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: () -> Unit) { private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: () -> Unit) {
val count = queuedPhotoUploadCount(uploads.map { it.status }) val count = queuedPhotoUploadCount(uploads.map { it.status })
@@ -1238,7 +1288,7 @@ private fun DetailScreen(
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp), contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) } item { ManifestSection(route, selectedDate = state.selectedDate, onNavigate = { openNavigation(context, it) }) }
item { OfflineStaleBanner(state) } item { OfflineStaleBanner(state) }
item { item {
RouteCompletionSection( RouteCompletionSection(
@@ -1456,8 +1506,9 @@ private fun photoCountLabel(count: Int): String =
} }
@Composable @Composable
private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) { private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status) val stripColor = routeStatusColor(route.status)
val routeDate = route.routeDate ?: selectedDate
Card( Card(
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@@ -1465,13 +1516,17 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
) { ) {
Column { Column {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
StatusChip(route.status, stripColor) StatusChip(route.status, stripColor)
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
RouteDateStatusChip(routeDate)
}
}
Text( Text(
"Zlecenie #${route.contractCode ?: route.id}", "Zlecenie #${route.contractCode ?: route.id}",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -1480,7 +1535,6 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
) )
} }
} }
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f)) HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
RoutePointBlock( RoutePointBlock(
label = "Załadunek", label = "Załadunek",
@@ -1509,6 +1563,32 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
} }
} }
@Composable
private fun RouteDateStatusChip(routeDate: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(Color(0xFFE9F2FF), RoundedCornerShape(3.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Outlined.CalendarToday,
contentDescription = null,
tint = Color(0xFF1D4E89),
modifier = Modifier.size(16.dp),
)
Text(
routeDateChipLabel(routeDate),
color = Color(0xFF1D4E89),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable @Composable
private fun RoutePointBlock( private fun RoutePointBlock(
label: String, label: String,
@@ -1,12 +1,17 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.io.File import java.io.File
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
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
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
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)
@@ -18,6 +23,38 @@ fun canCompleteRouteFromDriverApp(
canManageRoutePhotos(selectedDate, today) canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE") && route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
fun routeDateChipLabel(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val date = routeDate
?.takeIf { it.isNotBlank() }
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: return ""
val fallback = date.format(shortDateFormatter)
val days = ChronoUnit.DAYS.between(today, date)
val human = when {
days == 0L -> "dzisiaj"
days == 1L -> "jutro"
days in 2L..7L -> polishWeekdayPhrase(date.dayOfWeek)
else -> null
}
return human?.let { "$it · $fallback" } ?: fallback
}
private fun polishWeekdayPhrase(day: DayOfWeek): String =
when (day) {
DayOfWeek.MONDAY -> "w poniedziałek"
DayOfWeek.TUESDAY -> "we wtorek"
DayOfWeek.WEDNESDAY -> "w środę"
DayOfWeek.THURSDAY -> "w czwartek"
DayOfWeek.FRIDAY -> "w piątek"
DayOfWeek.SATURDAY -> "w sobotę"
DayOfWeek.SUNDAY -> "w niedzielę"
}
fun inlinePhotoGridRows(photoCount: Int): Int = fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2 if (photoCount <= 0) 0 else (photoCount + 1) / 2
@@ -56,6 +56,7 @@ data class DriverUiState(
val isOnline: Boolean = true, val isOnline: Boolean = true,
val isStale: Boolean = false, val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null, val lastSuccessfulSyncAtEpochMillis: Long? = null,
val routeDayLiveUpdateMessage: String? = null,
val feedback: String? = null, val feedback: String? = null,
val error: String? = null, val error: String? = null,
) )
@@ -137,7 +138,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun selectRouteDate(date: String) = loadRoutes(date = date, showLoading = true, navigateToRoutes = true) fun selectRouteDate(date: String) {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch { viewModelScope.launch {
@@ -457,8 +465,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value val snapshot = _state.value
when (hint.scope) { when (hint.scope) {
DriverSyncRepository.SCOPE_ROUTES -> { DriverSyncRepository.SCOPE_ROUTES -> {
if (hint.date == null || hint.date == snapshot.selectedDate) { if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) {
refreshRoutesSilently() refreshRoutesSilently()
if (snapshot.screen == DriverScreen.Routes) {
_state.update {
it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.")
}
}
} else { } else {
DriverSyncWorker.enqueue(getApplication(), hint.date, null) DriverSyncWorker.enqueue(getApplication(), hint.date, null)
} }
@@ -34,6 +34,23 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today)) assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
} }
@Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = null))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-13"))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "", hintDate = "2026-03-12"))
}
@Test
fun formatsRouteDateChipHumanFirstWithShortFallback() {
assertEquals("dzisiaj · 30.06", routeDateChipLabel("2026-06-30", today))
assertEquals("jutro · 01.07", routeDateChipLabel("2026-07-01", today))
assertEquals("w poniedziałek · 06.07", routeDateChipLabel("2026-07-06", today))
assertEquals("02.03", routeDateChipLabel("2027-03-02", today))
assertEquals("", routeDateChipLabel("", today))
}
@Test @Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0)) assertEquals(0, inlinePhotoGridRows(0))