From 0e15d5f9a4fbebd67b452b80da7b1681e253597a Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 2 Jul 2026 16:24:06 +0200 Subject: [PATCH] Show live day updates and route date chip --- app/build.gradle.kts | 4 +- .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 99 ++++++++++++++++--- .../pl/firmatpp/kierowca/ui/DriverUiRules.kt | 37 +++++++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 17 +++- .../firmatpp/kierowca/ui/DriverUiRulesTest.kt | 17 ++++ 5 files changed, 156 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d9e64b..85595c5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 35 - versionName = "1.0.34" + versionCode = 36 + versionName = "1.0.35" setProperty("archivesBaseName", "pl.firmatpp.kierowca") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index a8bfe5f..5d3578c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -203,6 +203,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) { onProfile = viewModel::openProfile, onPhotoQueue = viewModel::openPhotoQueue, onRoute = viewModel::openRoute, + onDismissLiveUpdate = viewModel::dismissRouteDayLiveUpdate, ) DriverScreen.Profile -> ProfileScreen( state = state, @@ -546,7 +547,15 @@ private fun RoutesScreen( onProfile: () -> Unit, onPhotoQueue: () -> Unit, onRoute: (String) -> Unit, + onDismissLiveUpdate: () -> Unit, ) { + LaunchedEffect(state.routeDayLiveUpdateMessage) { + if (!state.routeDayLiveUpdateMessage.isNullOrBlank()) { + delay(6_000) + onDismissLiveUpdate() + } + } + BoxWithConstraints(Modifier.fillMaxSize()) { val screenWidthDp = maxWidth.value.toInt() val screenHeightDp = maxHeight.value.toInt() @@ -599,6 +608,7 @@ private fun RoutesScreen( } } } + item { RouteDayLiveUpdateBanner(state.routeDayLiveUpdateMessage) } item { RouteDateSelector( selectedDate = state.selectedDate, @@ -628,6 +638,37 @@ private fun RoutesScreen( } } +@Composable +private fun RouteDayLiveUpdateBanner(message: String?) { + 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 private fun PhotoQueueBanner(uploads: List, onPhotoQueue: () -> Unit) { val count = queuedPhotoUploadCount(uploads.map { it.status }) @@ -1238,7 +1279,7 @@ private fun DetailScreen( contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.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 { RouteCompletionSection( @@ -1456,8 +1497,9 @@ private fun photoCountLabel(count: Int): String = } @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 routeDate = route.routeDate ?: selectedDate Card( colors = CardDefaults.cardColors(containerColor = Color.White), shape = RoundedCornerShape(8.dp), @@ -1465,20 +1507,23 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD ) { Column { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, - ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { StatusChip(route.status, stripColor) - Text( - "Zlecenie #${route.contractCode ?: route.id}", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = TppColors.Ink, - ) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + RouteDateStatusChip(routeDate) + } } + Text( + "Zlecenie #${route.contractCode ?: route.id}", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = TppColors.Ink, + ) } } HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f)) @@ -1509,6 +1554,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 private fun RoutePointBlock( label: String, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 27ae87f..f3dd6d1 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -1,12 +1,17 @@ package pl.firmatpp.kierowca.ui import java.io.File +import java.time.DayOfWeek import java.time.LocalDate 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.RoutePhotoDto import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity +private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") + fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false) @@ -18,6 +23,38 @@ fun canCompleteRouteFromDriverApp( canManageRoutePhotos(selectedDate, today) && 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 = if (photoCount <= 0) 0 else (photoCount + 1) / 2 diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 83db96c..2226ed2 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -56,6 +56,7 @@ data class DriverUiState( val isOnline: Boolean = true, val isStale: Boolean = false, val lastSuccessfulSyncAtEpochMillis: Long? = null, + val routeDayLiveUpdateMessage: String? = null, val feedback: 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 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) { viewModelScope.launch { @@ -457,8 +465,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val snapshot = _state.value when (hint.scope) { DriverSyncRepository.SCOPE_ROUTES -> { - if (hint.date == null || hint.date == snapshot.selectedDate) { + if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) { refreshRoutesSilently() + if (snapshot.screen == DriverScreen.Routes) { + _state.update { + it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.") + } + } } else { DriverSyncWorker.enqueue(getApplication(), hint.date, null) } diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index 16318e0..a2c699b 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -34,6 +34,23 @@ class DriverUiRulesTest { 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 fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { assertEquals(0, inlinePhotoGridRows(0))