Compare commits

...
4 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
admin 5555676395 Shorten driver Reverb heartbeat 2026-07-02 16:08:24 +02:00
admin ca512f5993 Load Reverb config from mobile bootstrap 2026-07-02 15:33:42 +02:00
7 changed files with 191 additions and 27 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 = 33 versionCode = 37
versionName = "1.0.32" versionName = "1.0.36"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -47,6 +47,7 @@ data class BootstrapResponse(
val routes: RoutesBucketDto, val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?, val driverAppSettings: DriverAppSettingsDto?,
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
) )
@@ -71,6 +72,12 @@ data class NotificationPreferencesDto(
val notifyNewRoutes: Boolean = false, val notifyNewRoutes: Boolean = false,
) )
data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
)
data class RoutesBucketDto( data class RoutesBucketDto(
val today: List<DriverRouteDto> = emptyList(), val today: List<DriverRouteDto> = emptyList(),
) )
@@ -15,8 +15,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
class DriverLiveSyncClient( class DriverLiveSyncClient(
private val repository: DriverRepository, private val repository: DriverRepository,
@@ -25,20 +25,29 @@ class DriverLiveSyncClient(
private val client: OkHttpClient = OkHttpClient(), private val client: OkHttpClient = OkHttpClient(),
private val gson: Gson = Gson(), private val gson: Gson = Gson(),
) { ) {
private companion object {
const val HEARTBEAT_INTERVAL_MS = 10_000L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private var driverId: String? = null private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null private var socketId: String? = null
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
fun start(driverId: String) { fun start(driverId: String, config: RealtimeConfigDto?) {
if (BuildConfig.REVERB_APP_KEY.isBlank()) return val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
if (!config.reverbEnabled) return
realtimeConfig = config
this.driverId = driverId this.driverId = driverId
if (!started.compareAndSet(false, true)) return if (!started.compareAndSet(false, true)) return
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') + val wsUrl = wsBaseUrl.trimEnd('/') +
"/" + BuildConfig.REVERB_APP_KEY + "/" + appKey +
"?protocol=7&client=android&version=1.0&flash=false" "?protocol=7&client=android&version=1.0&flash=false"
webSocket = client.newWebSocket( webSocket = client.newWebSocket(
@@ -72,6 +81,7 @@ class DriverLiveSyncClient(
webSocket?.close(1000, "logout") webSocket?.close(1000, "logout")
webSocket = null webSocket = null
driverId = null driverId = null
realtimeConfig = null
socketId = null socketId = null
} }
@@ -146,7 +156,7 @@ class DriverLiveSyncClient(
scope.launch { scope.launch {
delay(5_000) delay(5_000)
if (!started.get() && driverId == id) { if (!started.get() && driverId == id) {
start(id) start(id, realtimeConfig)
} }
} }
} }
@@ -156,7 +166,7 @@ class DriverLiveSyncClient(
heartbeatJob = scope.launch { heartbeatJob = scope.launch {
while (started.get()) { while (started.get()) {
reportRealtimeStatus("connected") reportRealtimeStatus("connected")
delay(30_000) delay(HEARTBEAT_INTERVAL_MS)
} }
} }
} }
@@ -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 {
@@ -168,7 +176,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
response.session.driver.id.let { driverId -> response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId) liveSyncClient.start(driverId, response.realtime)
registerPushTokenIfAvailable(driverId) registerPushTokenIfAvailable(driverId)
} }
}.onFailure { throwable -> }.onFailure { throwable ->
@@ -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))