Compare commits

..
34 changed files with 1400 additions and 108 deletions
+4 -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 = 107 versionCode = 114
versionName = "1.0.54" versionName = "1.0.61"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -130,6 +130,8 @@ dependencies {
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.play.services.auth) implementation(libs.play.services.auth)
implementation(libs.play.services.auth.api.phone) implementation(libs.play.services.auth.api.phone)
implementation(libs.play.app.update)
implementation(libs.play.app.update.ktx)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.gson) implementation(libs.retrofit.gson)
@@ -16,7 +16,10 @@ class DriverApplication : Application() {
AppDiagnostics.installFirebaseCrashlytics() AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started") AppDiagnostics.log("app_started")
applicationScope.launch { applicationScope.launch {
runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() } val outboxManager = OfflineOutboxManager(this@DriverApplication)
runCatching { outboxManager.repairOperationalQueues() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_operational_queues") }
runCatching { outboxManager.repairGpsQueue() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") } .onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
} }
} }
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -11,17 +12,33 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel import pl.firmatpp.kierowca.ui.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
import pl.firmatpp.kierowca.update.PlayInAppUpdateController
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null) private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationRouteTarget by mutableStateOf<String?>(null) private var notificationRouteTarget by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null) private var notificationLeaveRequestId by mutableStateOf<String?>(null)
private var playAppUpdateState by mutableStateOf(PlayAppUpdateState())
private lateinit var playInAppUpdates: PlayInAppUpdateController
private val appUpdateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.onUpdateFlowResult(result.resultCode)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET) notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
playInAppUpdates = PlayInAppUpdateController(
activity = this,
launcher = appUpdateLauncher,
onStateChanged = { playAppUpdateState = it },
)
setContent { setContent {
val viewModel: DriverViewModel = viewModel() val viewModel: DriverViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -32,9 +49,27 @@ class MainActivity : ComponentActivity() {
initialRouteId = notificationRouteId, initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget, initialRouteTarget = notificationRouteTarget,
initialLeaveRequestId = notificationLeaveRequestId, initialLeaveRequestId = notificationLeaveRequestId,
playAppUpdateState = playAppUpdateState,
onStartAppUpdate = playInAppUpdates::startUpdate,
onCompleteAppUpdate = playInAppUpdates::completeDownloadedUpdate,
) )
} }
} }
playInAppUpdates.checkForUpdate()
}
override fun onResume() {
super.onResume()
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.checkForUpdate()
}
}
override fun onDestroy() {
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.dispose()
}
super.onDestroy()
} }
override fun onNewIntent(intent: android.content.Intent) { override fun onNewIntent(intent: android.content.Intent) {
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import retrofit2.HttpException import retrofit2.HttpException
enum class ApiErrorKind { enum class ApiErrorKind {
@@ -106,3 +107,7 @@ object ApiErrorMapper {
return mapHttpStatus(exception.code(), body) return mapHttpStatus(exception.code(), body)
} }
} }
fun Throwable.rethrowIfCancellation() {
if (this is CancellationException) throw this
}
@@ -50,6 +50,16 @@ data class BootstrapResponse(
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null, val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
val appUpdate: AppUpdateDto? = null,
)
data class AppUpdateDto(
val latestVersionCode: Int? = null,
val latestVersionName: String? = null,
val minimumSupportedVersionCode: Int = 0,
val playStoreUrl: String? = null,
val track: String? = null,
val checkedAt: String? = null,
) )
data class DriverAppSettingsDto( data class DriverAppSettingsDto(
@@ -117,6 +127,7 @@ data class RealtimeConfigDto(
val reverbEnabled: Boolean = false, val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null, val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null, val reverbWsBaseUrl: String? = null,
val reverbOrigin: String? = null,
) )
data class RoutesBucketDto( data class RoutesBucketDto(
@@ -264,6 +275,8 @@ data class DriverRouteDto(
val contractCode: String?, val contractCode: String?,
val relationLabel: String, val relationLabel: String,
val status: String, val status: String,
val driverStatus: String? = null,
val operationalStatus: String? = null,
val loadingWeight: Double? = null, val loadingWeight: Double? = null,
val unloadingWeight: Double? = null, val unloadingWeight: Double? = null,
val trackingStatus: String? = null, val trackingStatus: String? = null,
@@ -272,6 +285,7 @@ data class DriverRouteDto(
val notes: String?, val notes: String?,
val truck: TruckDto?, val truck: TruckDto?,
val completedAt: String? = null, val completedAt: String? = null,
val operationalCompletedAt: String? = null,
val completedByDriver: DriverDto? = null, val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null, val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null,
@@ -279,6 +293,9 @@ data class DriverRouteDto(
val photosByStage: RoutePhotosByStageDto? = null, val photosByStage: RoutePhotosByStageDto? = null,
) )
fun DriverRouteDto.driverLifecycleStatus(): String =
driverStatus?.takeIf { it in setOf("ZAPLANOWANA", "W TRAKCIE", "ZAKOŃCZONA") } ?: status
data class RoutePhotosByStageDto( data class RoutePhotosByStageDto(
val loading: List<RoutePhotoDto> = emptyList(), val loading: List<RoutePhotoDto> = emptyList(),
val unloading: List<RoutePhotoDto> = emptyList(), val unloading: List<RoutePhotoDto> = emptyList(),
@@ -70,6 +70,9 @@ class DriverSyncRepository(
) )
} }
suspend fun hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? { suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null val cached = dao.route(routeId) ?: return null
return CachedValue( return CachedValue(
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
class StartupOfflineFallbackLoader(
private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS,
) {
suspend fun <T> load(
onlineLoad: suspend () -> T,
offlineLoad: suspend () -> T?,
): T {
val onlineValue = withTimeoutOrNull(timeoutMillis) {
onlineLoad()
}
if (onlineValue != null) {
return onlineValue
}
return offlineLoad()
?: throw IOException(
"Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " +
"i w telefonie nie ma zapisanych danych offline.",
)
}
}
@@ -63,12 +63,26 @@ class DispatchSheetUploadOutbox(
} }
suspend fun discard(clientRequestId: String) { suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload -> dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete() File(upload.localPath).delete()
dao.delete(upload.clientRequestId) dao.delete(upload.clientRequestId)
} }
} }
suspend fun retry(clientRequestId: String) {
val existing = dao.find(clientRequestId) ?: return
dao.upsert(
existing.copy(
status = PhotoUploadStatus.Pending.storageValue,
progress = 0,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientRequestId)
}
private fun enqueueWorker(clientRequestId: String) { private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>() val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId)) .setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -68,6 +69,7 @@ class DispatchSheetUploadWorker(
dao.delete(clientRequestId) dao.delete(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -36,6 +36,24 @@ class OfflineOutboxManager(private val context: Context) {
} }
} }
suspend fun repairOperationalQueues() {
val photoOutbox = PhotoUploadOutbox(context)
val dispatchSheetOutbox = DispatchSheetUploadOutbox(context)
val routeActionOutbox = RouteActionOutbox(context)
database.photoUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { photoOutbox.retry(it.clientRequestId) }
database.dispatchSheetUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { dispatchSheetOutbox.retry(it.clientRequestId) }
database.routeActionDao().allUnsent()
.filter { shouldRepairRouteAction(it.status, it.lastError) }
.forEach { routeActionOutbox.retry(it.clientActionId) }
}
suspend fun clearAll() { suspend fun clearAll() {
val photos = database.photoUploadDao().allRecords() val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords() val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
@@ -61,3 +79,31 @@ class OfflineOutboxManager(private val context: Context) {
database.routePointDao().deleteAll() database.routePointDao().deleteAll()
} }
} }
internal fun isWorkerCancellationFailure(lastError: String?): Boolean =
lastError?.let { error ->
error.contains("Job was cancelled", ignoreCase = true) ||
error.contains("CancellationException", ignoreCase = true)
} == true
internal fun isPhotoDependencyFailure(lastError: String?): Boolean =
lastError?.contains("Wymagane zdjęcie nie zostało wysłane", ignoreCase = true) == true
internal fun shouldRepairPhotoUpload(status: String, lastError: String?): Boolean =
status in setOf(
PhotoUploadStatus.Pending.storageValue,
PhotoUploadStatus.Uploading.storageValue,
PhotoUploadStatus.Verifying.storageValue,
PhotoUploadStatus.FailedRetryable.storageValue,
) || (status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
internal fun shouldRepairRouteAction(status: String, lastError: String?): Boolean =
status in setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
) || (
status == RouteActionStatus.FailedPermanent &&
(isWorkerCancellationFailure(lastError) || isPhotoDependencyFailure(lastError))
)
@@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadOutbox( class PhotoUploadOutbox(
private val context: Context, private val context: Context,
@@ -74,6 +75,7 @@ class PhotoUploadOutbox(
} }
suspend fun discard(clientRequestId: String) { suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload -> dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete() File(upload.localPath).delete()
dao.delete(upload.clientRequestId) dao.delete(upload.clientRequestId)
@@ -81,10 +83,13 @@ class PhotoUploadOutbox(
} }
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) { suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
dao.confirmedForRoute(route.id).forEach { upload -> dao.allRecords()
File(upload.localPath).delete() .filter { it.routeId == route.id }
dao.delete(upload.clientRequestId) .filter { upload ->
upload.status == PhotoUploadStatus.Confirmed.storageValue ||
route.photos.orEmpty().any { serverPhoto -> serverPhotoConfirmsUpload(upload, serverPhoto) }
} }
.forEach { upload -> discard(upload.clientRequestId) }
} }
private fun enqueueWorker(clientRequestId: String) { private fun enqueueWorker(clientRequestId: String) {
@@ -118,3 +123,9 @@ class PhotoUploadOutbox(
return digest.digest().joinToString("") { "%02x".format(it) } return digest.digest().joinToString("") { "%02x".format(it) }
} }
} }
internal fun serverPhotoConfirmsUpload(upload: PhotoUploadEntity, serverPhoto: RoutePhotoDto): Boolean =
serverPhoto.clientRequestId == upload.clientRequestId && (
serverPhoto.contentSha256.isNullOrBlank() ||
serverPhoto.contentSha256.equals(upload.contentSha256, ignoreCase = true)
)
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -70,6 +71,7 @@ class PhotoUploadWorker(
enqueueRouteActionsWaitingForPhoto(clientRequestId) enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -31,6 +31,18 @@ class RouteActionOutbox(
dao.delete(clientActionId) dao.delete(clientActionId)
} }
suspend fun retry(clientActionId: String) {
val existing = dao.find(clientActionId) ?: return
dao.upsert(
existing.copy(
status = RouteActionStatus.Pending,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientActionId)
}
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds)
@@ -7,6 +7,7 @@ import com.google.gson.Gson
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.StartRouteBody
@@ -75,6 +76,7 @@ class RouteActionWorker(
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -8,6 +8,7 @@ import kotlinx.coroutines.delay
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointDto import pl.firmatpp.kierowca.data.model.RoutePointDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -52,6 +53,7 @@ class RoutePointWorker(
} }
val throwable = outcome.exceptionOrNull() ?: return Result.retry() val throwable = outcome.exceptionOrNull() ?: return Result.retry()
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.domain
import kotlin.math.roundToInt import kotlin.math.roundToInt
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
data class RouteCardDisplay( data class RouteCardDisplay(
val id: String, val id: String,
@@ -32,7 +33,7 @@ object RouteDisplayMapper {
destination = route.destinationName.ifBlank { "Cel" }, destination = route.destinationName.ifBlank { "Cel" },
contractLabel = contract, contractLabel = contract,
time = route.startsAt.ifBlank { "--:--" }, time = route.startsAt.ifBlank { "--:--" },
status = route.status, status = route.driverLifecycleStatus(),
distanceLabel = distance, distanceLabel = distance,
) )
} }
@@ -0,0 +1,51 @@
package pl.firmatpp.kierowca.domain
data class StartupSessionMessage(
val title: String,
val detail: String,
)
object StartupSessionPolicy {
const val OFFLINE_BUTTON_DELAY_SECONDS = 4
const val OFFLINE_FALLBACK_SECONDS = 15
const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L
fun remainingSeconds(elapsedSeconds: Int): Int =
(OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0)
fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean =
offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS
fun message(elapsedSeconds: Int): StartupSessionMessage = when {
elapsedSeconds < 3 -> StartupSessionMessage(
title = "Sprawdzamy zapisaną sesję",
detail = "Odczytujemy bezpieczne logowanie kierowcy.",
)
elapsedSeconds < 6 -> StartupSessionMessage(
title = "Potwierdzamy logowanie",
detail = "Łączymy telefon z serwerem.",
)
elapsedSeconds < 9 -> StartupSessionMessage(
title = "Pobieramy aktualne kursy",
detail = "Synchronizujemy plan dnia i ustawienia aplikacji.",
)
elapsedSeconds < 12 -> StartupSessionMessage(
title = "To trwa dłużej niż zwykle",
detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.",
)
else -> StartupSessionMessage(
title = "Przygotowujemy tryb offline",
detail = "Za chwilę pokażemy ostatnie zapisane kursy.",
)
}
fun countdownLabel(elapsedSeconds: Int): String {
val remaining = remainingSeconds(elapsedSeconds)
return if (remaining > 0) {
"Tryb offline najpóźniej za $remaining s"
} else {
"Otwieramy zapisane dane offline"
}
}
}
@@ -43,17 +43,17 @@ class DriverRepositoryLiveSyncGateway(
} }
interface LiveWebSocketFactory { interface LiveWebSocketFactory {
fun newWebSocket(url: String, listener: WebSocketListener): WebSocket fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket
} }
class OkHttpLiveWebSocketFactory( class OkHttpLiveWebSocketFactory(
private val client: OkHttpClient, private val client: OkHttpClient,
) : LiveWebSocketFactory { ) : LiveWebSocketFactory {
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket = override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket =
client.newWebSocket( client.newWebSocket(
Request.Builder() Request.Builder()
.url(url) .url(url)
.header("Origin", websocketOrigin(url)) .header("Origin", origin?.takeIf { it.isNotBlank() } ?: websocketOrigin(url))
.build(), .build(),
listener, listener,
) )
@@ -248,7 +248,7 @@ class DriverLiveSyncClient(
} }
private fun connectNow(resetAttempt: Boolean = false) { private fun connectNow(resetAttempt: Boolean = false) {
val wsUrl = synchronized(lock) { val (wsUrl, origin) = synchronized(lock) {
val config = realtimeConfig ?: return val config = realtimeConfig ?: return
val id = driverId ?: return val id = driverId ?: return
if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return if (!desiredActive || !foregroundActive || !networkAvailable || !isConfigUsable(config)) return
@@ -262,11 +262,15 @@ class DriverLiveSyncClient(
val appKey = config.reverbAppKey.orEmpty() val appKey = config.reverbAppKey.orEmpty()
val wsBaseUrl = config.reverbWsBaseUrl.orEmpty() val wsBaseUrl = config.reverbWsBaseUrl.orEmpty()
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false" Pair(
wsBaseUrl.trimEnd('/') + "/" + appKey + "?protocol=7&client=android&version=1.0&flash=false",
config.reverbOrigin,
)
} }
val socket = webSocketFactory.newWebSocket( val socket = webSocketFactory.newWebSocket(
wsUrl, wsUrl,
origin,
object : WebSocketListener() { object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text) handleMessage(webSocket, text)
@@ -13,6 +13,7 @@ import androidx.work.workDataOf
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository 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.SyncScopeDto import pl.firmatpp.kierowca.data.model.SyncScopeDto
@@ -79,6 +80,7 @@ class DriverSyncWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException import retrofit2.HttpException
@@ -75,6 +76,7 @@ class NewRouteNotificationWorker(
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification) NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
Result.success() Result.success()
} else { } else {
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.ui
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -12,9 +13,13 @@ import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
@@ -69,6 +74,7 @@ import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
@@ -115,6 +121,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -143,6 +150,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged 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.BuildConfig
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.DriverLeaveCalendarEntryDto
@@ -150,6 +158,7 @@ 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
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -159,8 +168,10 @@ import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity
import pl.firmatpp.kierowca.sync.LiveSyncConnectionState import pl.firmatpp.kierowca.sync.LiveSyncConnectionState
import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.AppThemeMode
import pl.firmatpp.kierowca.ui.theme.TppTheme import pl.firmatpp.kierowca.ui.theme.TppTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
@Composable @Composable
fun DriverApp( fun DriverApp(
@@ -168,8 +179,27 @@ fun DriverApp(
initialRouteId: String? = null, initialRouteId: String? = null,
initialRouteTarget: String? = null, initialRouteTarget: String? = null,
initialLeaveRequestId: String? = null, initialLeaveRequestId: String? = null,
playAppUpdateState: PlayAppUpdateState = PlayAppUpdateState(),
onStartAppUpdate: (required: Boolean, playStoreUrl: String?) -> Unit = { _, _ -> },
onCompleteAppUpdate: () -> Unit = {},
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val updateUi = appUpdateBannerUi(
currentVersionCode = BuildConfig.VERSION_CODE,
latestVersionCode = state.appUpdate?.latestVersionCode,
latestVersionName = state.appUpdate?.latestVersionName,
minimumSupportedVersionCode = state.appUpdate?.minimumSupportedVersionCode ?: 0,
playUpdateAvailable = playAppUpdateState.updateAvailable,
playAvailableVersionCode = playAppUpdateState.availableVersionCode,
updateDownloaded = playAppUpdateState.downloaded,
)
val runUpdate = {
if (updateUi?.downloaded == true) {
onCompleteAppUpdate()
} else {
onStartAppUpdate(updateUi?.required == true, state.appUpdate?.playStoreUrl)
}
}
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current val context = LocalContext.current
var appInForeground by remember { mutableStateOf(true) } var appInForeground by remember { mutableStateOf(true) }
@@ -228,10 +258,29 @@ fun DriverApp(
viewModel.openLeaveRequest(initialLeaveRequestId) viewModel.openLeaveRequest(initialLeaveRequestId)
} }
} }
LaunchedEffect(state.screen, state.notifyNewRoutes, state.notificationPermissionDenied, appInForeground) {
if (!appInForeground || state.screen != DriverScreen.Profile || !state.notifyNewRoutes) return@LaunchedEffect
if (canPostNotifications(context)) {
if (state.notificationPermissionDenied) {
viewModel.clearNotificationPermissionWarning()
}
return@LaunchedEffect
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPostNotificationsRuntimePermission(context)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.markNotificationPermissionBlocked()
}
}
Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) { Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) {
when (state.screen) { when (state.screen) {
DriverScreen.Initializing -> StartupScreen() DriverScreen.Initializing -> StartupScreen(
offlineDataAvailable = state.startupOfflineAvailable,
onUseOfflineNow = viewModel::useOfflineStartupDataNow,
)
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp) DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back) DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
DriverScreen.Routes -> RoutesScreen( DriverScreen.Routes -> RoutesScreen(
@@ -295,7 +344,7 @@ fun DriverApp(
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::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute,
) )
@@ -312,7 +361,7 @@ fun DriverApp(
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { _, _, _ -> }, onUpload = { _, _, _ -> },
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitStartRoute, onSubmit = viewModel::submitStartRoute,
) )
@@ -329,7 +378,7 @@ fun DriverApp(
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") }, onUpload = { uri, source, metadata -> viewModel.uploadPhoto(uri, source, metadata, "unloading") },
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deleteConfirmedUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitFinishRoute, onSubmit = viewModel::submitFinishRoute,
) )
@@ -372,11 +421,99 @@ fun DriverApp(
CircularProgressIndicator(color = TppTheme.colors.forest) CircularProgressIndicator(color = TppTheme.colors.forest)
} }
} }
if (updateUi != null && !updateUi.required) {
AppUpdateBanner(
update = updateUi,
onUpdate = runUpdate,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
if (updateUi?.required == true) {
AlertDialog(
onDismissRequest = {},
title = {
Text(
"Wymagana aktualizacja",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
text = { Text(updateUi.message, color = TppTheme.colors.muted) },
confirmButton = {
Button(
onClick = runUpdate,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
) {
Text(updateUi.actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
},
)
} }
} }
@Composable @Composable
private fun StartupScreen() { private fun AppUpdateBanner(
update: AppUpdateBannerUi,
onUpdate: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer),
border = BorderStroke(1.dp, TppTheme.colors.warningOutline),
shape = MaterialTheme.shapes.medium,
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.SystemUpdate, contentDescription = null, tint = TppTheme.colors.forest)
}
Text(
update.message,
color = Color(0xFF5F4200),
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Button(
onClick = onUpdate,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.small,
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
) {
Text(update.actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun StartupScreen(
offlineDataAvailable: Boolean,
onUseOfflineNow: () -> Unit,
) {
var elapsedSeconds by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (elapsedSeconds < StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS) {
delay(1_000L)
elapsedSeconds += 1
}
}
val sessionMessage = StartupSessionPolicy.message(elapsedSeconds)
val countdownLabel = StartupSessionPolicy.countdownLabel(elapsedSeconds)
val showOfflineButton = StartupSessionPolicy.shouldShowOfflineButton(elapsedSeconds, offlineDataAvailable)
BoxWithConstraints( BoxWithConstraints(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -401,13 +538,100 @@ private fun StartupScreen() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
CircularProgressIndicator(color = TppTheme.colors.forest) CircularProgressIndicator(color = TppTheme.colors.forest)
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(22.dp))
Text( Text(
"Sprawdzanie sesji", "URUCHAMIAMY APLIKACJĘ",
color = TppTheme.colors.muted, color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
fontSize = 12.sp,
) )
Spacer(Modifier.height(10.dp))
AnimatedContent(
targetState = sessionMessage,
transitionSpec = {
fadeIn(animationSpec = tween(260)) togetherWith
fadeOut(animationSpec = tween(220))
},
label = "startup-session-message",
) { message ->
Column(
modifier = Modifier.heightIn(min = 72.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
Text(
message.title,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(6.dp))
Text(
message.detail,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.muted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
Spacer(Modifier.height(14.dp))
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.panel),
border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)),
shape = RoundedCornerShape(6.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = countdownLabel,
transitionSpec = {
fadeIn(animationSpec = tween(180)) togetherWith
fadeOut(animationSpec = tween(140))
},
label = "startup-offline-countdown",
) { label ->
Text(
label,
color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Jeśli kursy były wcześniej zapisane w telefonie.",
color = TppTheme.colors.muted,
fontSize = 12.sp,
)
}
}
AnimatedVisibility(
visible = showOfflineButton,
enter = fadeIn(animationSpec = tween(260)),
exit = fadeOut(animationSpec = tween(160)),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onUseOfflineNow,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
border = BorderStroke(1.dp, TppTheme.colors.forest),
colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.forest),
shape = RoundedCornerShape(6.dp),
) {
Text(
"Przejdź do trybu offline teraz",
fontWeight = FontWeight.Bold,
)
}
}
}
} }
} }
} }
@@ -1866,7 +2090,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
) )
} }
} }
if (hasFailureDetails || canRetryPhotoUpload(upload.status)) { if (hasFailureDetails || canRetryPhotoUpload(upload.status, upload.lastError)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
if (hasFailureDetails) { if (hasFailureDetails) {
IconButton( IconButton(
@@ -1884,7 +2108,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
) )
} }
} }
if (canRetryPhotoUpload(upload.status)) { if (canRetryPhotoUpload(upload.status, upload.lastError)) {
Button( Button(
onClick = { onRetryUpload(upload) }, onClick = { onRetryUpload(upload) },
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
@@ -2021,7 +2245,8 @@ private fun routeListTitle(selectedDate: String): String {
@Composable @Composable
private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
val display = RouteDisplayMapper.toCard(route) val display = RouteDisplayMapper.toCard(route)
val stripColor = routeStatusColor(route.status) val driverStatus = route.driverLifecycleStatus()
val stripColor = routeStatusColor(driverStatus)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -2043,7 +2268,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) { Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) {
if (compactWidth) { if (compactWidth) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
StatusChip(route.status, stripColor) StatusChip(driverStatus, stripColor)
Text( Text(
"Kontrakt: #${display.contractLabel.trimStart('#')}", "Kontrakt: #${display.contractLabel.trimStart('#')}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -2057,7 +2282,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
StatusChip(route.status, stripColor) StatusChip(driverStatus, stripColor)
Text( Text(
"Kontrakt: #${display.contractLabel.trimStart('#')}", "Kontrakt: #${display.contractLabel.trimStart('#')}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -2238,13 +2463,18 @@ private fun ProfileScreen(
Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall) Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall)
} }
if (state.notificationPermissionDenied) { if (state.notificationPermissionDenied) {
Text(
"Android blokuje powiadomienia dla tej aplikacji. Token FCM może być zarejestrowany, ale powiadomienie nie pojawi się na telefonie, dopóki nie włączysz zgody systemowej.",
color = TppTheme.colors.error,
style = MaterialTheme.typography.bodySmall,
)
Button( Button(
onClick = onOpenNotificationSettings, onClick = onOpenNotificationSettings,
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
) { ) {
Text("Przejdź do ustawień", fontWeight = FontWeight.Bold) Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold)
} }
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
@@ -2621,26 +2851,27 @@ 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 attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size) val usableStageUploads = routeStageUsableUploads(stageUploads)
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, usableStageUploads.size)
val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement
val submitBlocker = when { val submitBlocker = when {
!showWeightInput -> loadingPhotosSubmitBlocker( !showWeightInput -> loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = usableStageUploads.size,
photoRequirement = photoRequirement, photoRequirement = photoRequirement,
) )
stage == "loading" -> loadingWeightSubmitBlocker( stage == "loading" -> loadingWeightSubmitBlocker(
weightText = state.routeStageWeightText, weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = usableStageUploads.size,
weightRequirement = weightRequirement, weightRequirement = weightRequirement,
photoRequirement = photoRequirement, photoRequirement = photoRequirement,
) )
else -> routeStageSubmitBlocker( else -> routeStageSubmitBlocker(
weightText = state.routeStageWeightText, weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = usableStageUploads.size,
weightRequirement = weightRequirement, weightRequirement = weightRequirement,
photoRequirement = photoRequirement, photoRequirement = photoRequirement,
) )
@@ -2725,16 +2956,14 @@ private fun RouteStageScreen(
label = { Text(weightLabel) }, label = { Text(weightLabel) },
trailingIcon = { trailingIcon = {
Text( Text(
"t", "t / kg",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
}, },
supportingText = { supportingText = {
if (weightBlocker != null) { Text(weightBlocker ?: routeWeightInputHelpText(state.routeStageWeightText))
Text(weightBlocker)
}
}, },
isError = weightBlocker != null, isError = weightBlocker != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
@@ -3124,7 +3353,7 @@ private fun RouteLifecycleSection(
) )
} }
if (route.status == "W TRAKCIE" && !routeProgressNotificationEnabled) { if (route.driverLifecycleStatus() == "W TRAKCIE" && !routeProgressNotificationEnabled) {
Text( Text(
"Powiadomienie działa w trybie minimalnym.", "Powiadomienie działa w trybie minimalnym.",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
@@ -3235,7 +3464,8 @@ private fun RouteFlowStepItem(step: RouteFlowStepUi, modifier: Modifier = Modifi
private fun routeFlowStepColor(state: RouteFlowStepState): Color = private fun routeFlowStepColor(state: RouteFlowStepState): Color =
when (state) { when (state) {
RouteFlowStepState.Todo -> TppTheme.colors.muted RouteFlowStepState.Todo -> TppTheme.colors.muted
RouteFlowStepState.LocalComplete -> TppTheme.colors.navy RouteFlowStepState.ServerSaved,
RouteFlowStepState.InProgress -> TppTheme.colors.navy
RouteFlowStepState.Syncing -> TppTheme.colors.containerGreen RouteFlowStepState.Syncing -> TppTheme.colors.containerGreen
RouteFlowStepState.Confirmed -> TppTheme.colors.forest RouteFlowStepState.Confirmed -> TppTheme.colors.forest
RouteFlowStepState.NeedsAttention -> TppTheme.colors.error RouteFlowStepState.NeedsAttention -> TppTheme.colors.error
@@ -3245,7 +3475,8 @@ private fun routeFlowStepColor(state: RouteFlowStepState): Color =
private fun routeFlowStepBackground(state: RouteFlowStepState): Color = private fun routeFlowStepBackground(state: RouteFlowStepState): Color =
when (state) { when (state) {
RouteFlowStepState.Todo -> TppTheme.colors.panel.copy(alpha = 0.62f) RouteFlowStepState.Todo -> TppTheme.colors.panel.copy(alpha = 0.62f)
RouteFlowStepState.LocalComplete -> Color(0xFFE9F2FF) RouteFlowStepState.ServerSaved,
RouteFlowStepState.InProgress -> Color(0xFFE9F2FF)
RouteFlowStepState.Syncing -> TppTheme.colors.successContainer.copy(alpha = 0.75f) RouteFlowStepState.Syncing -> TppTheme.colors.successContainer.copy(alpha = 0.75f)
RouteFlowStepState.Confirmed -> TppTheme.colors.successContainer RouteFlowStepState.Confirmed -> TppTheme.colors.successContainer
RouteFlowStepState.NeedsAttention -> TppTheme.colors.warningContainer RouteFlowStepState.NeedsAttention -> TppTheme.colors.warningContainer
@@ -3254,7 +3485,8 @@ private fun routeFlowStepBackground(state: RouteFlowStepState): Color =
private fun routeFlowStepIcon(state: RouteFlowStepState): ImageVector = private fun routeFlowStepIcon(state: RouteFlowStepState): ImageVector =
when (state) { when (state) {
RouteFlowStepState.Todo -> Icons.Outlined.Info RouteFlowStepState.Todo -> Icons.Outlined.Info
RouteFlowStepState.LocalComplete -> Icons.Outlined.LocalShipping RouteFlowStepState.ServerSaved -> Icons.Outlined.CheckCircle
RouteFlowStepState.InProgress -> Icons.Outlined.LocalShipping
RouteFlowStepState.Syncing -> Icons.Outlined.CloudUpload RouteFlowStepState.Syncing -> Icons.Outlined.CloudUpload
RouteFlowStepState.Confirmed -> Icons.Outlined.CheckCircle RouteFlowStepState.Confirmed -> Icons.Outlined.CheckCircle
RouteFlowStepState.NeedsAttention -> Icons.Outlined.Info RouteFlowStepState.NeedsAttention -> Icons.Outlined.Info
@@ -3304,7 +3536,8 @@ private fun leaveConflictCountLabel(count: Int): String =
@Composable @Composable
private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) { private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status) val driverStatus = route.driverLifecycleStatus()
val stripColor = routeStatusColor(driverStatus)
val routeDate = route.routeDate ?: selectedDate val routeDate = route.routeDate ?: selectedDate
Card( Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -3319,7 +3552,7 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
StatusChip(route.status, stripColor) StatusChip(driverStatus, stripColor)
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
RouteDateStatusChip(routeDate) RouteDateStatusChip(routeDate)
} }
@@ -3430,7 +3663,10 @@ private fun RoutePointBlock(
Text(subtitle, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyLarge) Text(subtitle, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyLarge)
} }
} }
if (navigationPoint?.hasCoordinates() == true) { if (
navigationPoint != null &&
hasValidNavigationCoordinates(navigationPoint.latitude, navigationPoint.longitude)
) {
IconButton( IconButton(
onClick = { onNavigate(navigationPoint) }, onClick = { onNavigate(navigationPoint) },
modifier = Modifier.size(42.dp).background(TppTheme.colors.forest, RoundedCornerShape(21.dp)), modifier = Modifier.size(42.dp).background(TppTheme.colors.forest, RoundedCornerShape(21.dp)),
@@ -3673,7 +3909,7 @@ private fun PhotoGrid(
) )
is PhotoGridItem.Upload -> PendingPhotoTile( is PhotoGridItem.Upload -> PendingPhotoTile(
upload = item.upload, upload = item.upload,
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true, isDeleting = (item.upload.serverPhotoId ?: item.upload.clientRequestId) in deletingPhotoIds,
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) }, onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) }, onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
onRetry = onRetryUpload, onRetry = onRetryUpload,
@@ -3823,6 +4059,7 @@ private fun PendingPhotoTile(
) { ) {
val status = upload.statusType val status = upload.statusType
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
val canDeleteUpload = canDeleteServerPhoto || canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError) val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
var showFailureDetails by remember { mutableStateOf(false) } var showFailureDetails by remember { mutableStateOf(false) }
if (showFailureDetails) { if (showFailureDetails) {
@@ -3878,7 +4115,7 @@ private fun PendingPhotoTile(
Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
} }
} }
if (!readOnly && canDeleteServerPhoto) { if (!readOnly && canDeleteUpload) {
IconButton( IconButton(
onClick = { onDelete(upload) }, onClick = { onDelete(upload) },
enabled = !isDeleting, enabled = !isDeleting,
@@ -3916,13 +4153,13 @@ private fun PendingPhotoTile(
) )
} }
} }
if (!readOnly && canRetryPhotoUpload(upload.status)) { if (!readOnly && canRetryPhotoUpload(upload.status, upload.lastError)) {
IconButton( IconButton(
onClick = { onRetry(upload) }, onClick = { onRetry(upload) },
enabled = !isDeleting, enabled = !isDeleting,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp) .padding(top = if (canDeleteUpload) 56.dp else 8.dp, end = 8.dp)
.size(40.dp) .size(40.dp)
.background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) .background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
.border(1.dp, TppTheme.colors.forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), .border(1.dp, TppTheme.colors.forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
@@ -4220,24 +4457,29 @@ private fun createCameraUri(context: Context): Uri {
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
} }
private fun NavigationPointDto.hasCoordinates(): Boolean = latitude != null && longitude != null
private fun openNavigation(context: Context, point: NavigationPointDto) { private fun openNavigation(context: Context, point: NavigationPointDto) {
val latitude = point.latitude ?: return val candidates = navigationIntentCandidates(point.latitude, point.longitude, point.label)
val longitude = point.longitude ?: return
val googleMapsIntent = Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=$latitude,$longitude"))
.setPackage("com.google.android.apps.maps")
if (googleMapsIntent.resolveActivity(context.packageManager) != null) { for (candidate in candidates) {
context.startActivity(googleMapsIntent) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(candidate.uri)).apply {
candidate.packageName?.let(::setPackage)
}
try {
context.startActivity(intent)
return return
} catch (_: ActivityNotFoundException) {
// Try the next navigation provider.
} catch (_: SecurityException) {
// The provider is unavailable to this app; use the next fallback.
}
} }
val label = Uri.encode(point.label?.takeIf { it.isNotBlank() } ?: "Cel") Toast.makeText(
val geoIntent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$latitude,$longitude($label)")) context,
if (geoIntent.resolveActivity(context.packageManager) != null) { "Nie znaleziono aplikacji, która może otworzyć nawigację.",
context.startActivity(geoIntent) Toast.LENGTH_LONG,
} ).show()
} }
private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList { private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList {
@@ -40,6 +40,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
RouteActionType.Start -> { RouteActionType.Start -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "W TRAKCIE", status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
trackingStatus = "active", trackingStatus = "active",
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
@@ -49,6 +50,7 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA", status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight, unloadingWeight = action.weight,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = action.occurredAt, completedAt = action.occurredAt,
@@ -1,24 +1,113 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.io.File import java.io.File
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import java.time.DayOfWeek 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.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val routeWeightKilogramInputThreshold = BigDecimal("1000")
private const val routeWeightMaxTons = 999.999
private const val googleMapsPackage = "com.google.android.apps.maps"
data class NavigationIntentCandidate(
val uri: String,
val packageName: String? = null,
)
data class AppUpdateBannerUi(
val message: String,
val actionLabel: String,
val required: Boolean,
val downloaded: Boolean,
)
fun appUpdateBannerUi(
currentVersionCode: Int,
latestVersionCode: Int?,
latestVersionName: String?,
minimumSupportedVersionCode: Int,
playUpdateAvailable: Boolean,
playAvailableVersionCode: Int?,
updateDownloaded: Boolean,
): AppUpdateBannerUi? {
val highestAvailableVersion = listOfNotNull(latestVersionCode, playAvailableVersionCode).maxOrNull()
val required = minimumSupportedVersionCode > 0 && currentVersionCode < minimumSupportedVersionCode
val available = updateDownloaded ||
playUpdateAvailable ||
highestAvailableVersion?.let { it > currentVersionCode } == true ||
required
if (!available) return null
val versionLabel = latestVersionName
?.takeIf { it.isNotBlank() }
?.let { " $it" }
.orEmpty()
val message = when {
updateDownloaded -> "Aktualizacja$versionLabel jest pobrana i gotowa do instalacji."
required -> "Ta wersja aplikacji nie jest już obsługiwana. Zaktualizuj ją, aby kontynuować."
else -> "Dostępna jest nowa wersja aplikacji$versionLabel."
}
return AppUpdateBannerUi(
message = message,
actionLabel = if (updateDownloaded) "Zainstaluj" else "Aktualizuj",
required = required,
downloaded = updateDownloaded,
)
}
fun hasValidNavigationCoordinates(latitude: Double?, longitude: Double?): Boolean =
latitude != null &&
longitude != null &&
latitude.isFinite() &&
longitude.isFinite() &&
latitude in -90.0..90.0 &&
longitude in -180.0..180.0
fun navigationIntentCandidates(
latitude: Double?,
longitude: Double?,
label: String?,
): List<NavigationIntentCandidate> {
if (!hasValidNavigationCoordinates(latitude, longitude)) return emptyList()
val coordinates = "$latitude,$longitude"
val encodedLabel = URLEncoder
.encode(label?.takeIf { it.isNotBlank() } ?: "Cel", Charsets.UTF_8.name())
.replace("+", "%20")
return listOf(
NavigationIntentCandidate(
uri = "google.navigation:q=$coordinates",
packageName = googleMapsPackage,
),
NavigationIntentCandidate(uri = "geo:0,0?q=$coordinates($encodedLabel)"),
NavigationIntentCandidate(uri = "https://www.google.com/maps/dir/?api=1&destination=$coordinates"),
)
}
enum class RouteFlowStepState { enum class RouteFlowStepState {
Todo, Todo,
LocalComplete, ServerSaved,
InProgress,
Syncing, Syncing,
Confirmed, Confirmed,
NeedsAttention, NeedsAttention,
@@ -97,10 +186,10 @@ fun routeTodayInstruction(
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
return when { return when {
route.status == "ZAKOŃCZONA" -> "Kurs zakończony." route.driverLifecycleStatus() == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek." loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
today == loadingDate -> "Dziś: załadunek." today == loadingDate -> "Dziś: załadunek."
today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj." today == unloadingDate && route.driverLifecycleStatus() == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
today == unloadingDate -> "Dziś: rozładunek." today == unloadingDate -> "Dziś: rozładunek."
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}." else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
} }
@@ -113,7 +202,7 @@ fun canCompleteRouteFromDriverApp(
): Boolean = ): Boolean =
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE") && route.driverLifecycleStatus() in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp( fun canStartRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
@@ -121,14 +210,14 @@ fun canStartRouteFromDriverApp(
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
routeIsActiveToday(route, selectedDate, today) routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA" && route.driverLifecycleStatus() == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp( fun canFinishRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
route.status == "W TRAKCIE" route.driverLifecycleStatus() == "W TRAKCIE"
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today && runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
@@ -150,6 +239,31 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
fun parseRouteWeightTons(weightText: String): Double? {
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
enteredWeight.movePointLeft(3)
} else {
enteredWeight
}
return tons.setScale(3, RoundingMode.HALF_UP).toDouble()
}
fun routeWeightInputHelpText(weightText: String): String {
val enteredWeight = weightText.trim().replace(',', '.').toDoubleOrNull()
val tons = parseRouteWeightTons(weightText)
val enteredAsKilograms = enteredWeight != null &&
abs(enteredWeight) >= routeWeightKilogramInputThreshold.toDouble() &&
tons != null
return if (enteredAsKilograms) {
"Wpisano kilogramy — zapiszemy ${String.format(Locale("pl", "PL"), "%.3f", tons)} t."
} else {
"Wpisz tony (np. 23,4). Wartości od 1000 rozpoznajemy jako kilogramy (np. 23400)."
}
}
fun canSubmitRouteStageForm( fun canSubmitRouteStageForm(
weightText: String, weightText: String,
serverPhotoCount: Int, serverPhotoCount: Int,
@@ -188,13 +302,14 @@ fun loadingWeightSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku." loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null else -> null
} }
@@ -208,13 +323,14 @@ fun routeStageSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu." routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null else -> null
} }
@@ -264,29 +380,30 @@ fun routeFlowSteps(
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 loadingPhotoCount = routePhotosForStage(route.photos, "loading").size val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
val driverStatus = route.driverLifecycleStatus()
val loadingPhotosState = when { val loadingPhotosState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed startOverride != null -> startOverride
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete loadingPhotoCount > 0 -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val loadingWeightState = startOverride ?: when { val loadingWeightState = startOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete route.loadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val transitState = when { val transitState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed finishOverride != null -> RouteFlowStepState.InProgress
finishOverride == RouteFlowStepState.NeedsAttention -> RouteFlowStepState.LocalComplete driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
finishOverride == RouteFlowStepState.Syncing -> RouteFlowStepState.LocalComplete startOverride != null -> startOverride
route.status == "W TRAKCIE" -> RouteFlowStepState.LocalComplete driverStatus == "W TRAKCIE" -> RouteFlowStepState.InProgress
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val unloadingState = finishOverride ?: when { val unloadingState = finishOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.unloadingWeight != null -> RouteFlowStepState.LocalComplete route.unloadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
@@ -347,7 +464,8 @@ private fun routeActionStepState(actions: List<RouteActionEntity>, actionType: S
private fun routeFlowStateLabel(state: RouteFlowStepState): String = private fun routeFlowStateLabel(state: RouteFlowStepState): String =
when (state) { when (state) {
RouteFlowStepState.Todo -> "do zrobienia" RouteFlowStepState.Todo -> "do zrobienia"
RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie" RouteFlowStepState.ServerSaved -> "zapisane na serwerze"
RouteFlowStepState.InProgress -> "w trakcie"
RouteFlowStepState.Syncing -> "synchronizacja" RouteFlowStepState.Syncing -> "synchronizacja"
RouteFlowStepState.Confirmed -> "potwierdzone" RouteFlowStepState.Confirmed -> "potwierdzone"
RouteFlowStepState.NeedsAttention -> "wymaga uwagi" RouteFlowStepState.NeedsAttention -> "wymaga uwagi"
@@ -441,6 +559,18 @@ fun routeFactColumns(screenWidthDp: Int): Int =
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int = fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0) serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
fun photoUploadCanSatisfyStage(status: String): Boolean =
status in setOf(
PhotoUploadStatus.Pending.storageValue,
PhotoUploadStatus.Uploading.storageValue,
PhotoUploadStatus.Verifying.storageValue,
PhotoUploadStatus.Confirmed.storageValue,
PhotoUploadStatus.FailedRetryable.storageValue,
)
fun routeStageUsableUploads(uploads: List<PhotoUploadEntity>): List<PhotoUploadEntity> =
uploads.filter { photoUploadCanSatisfyStage(it.status) }
fun queuedPhotoUploadCount(statuses: List<String>): Int = fun queuedPhotoUploadCount(statuses: List<String>): Int =
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" } statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
@@ -452,8 +582,15 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? = fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() } serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean = fun canRetryPhotoUpload(status: String, lastError: String? = null): Boolean =
status == "FAILED_RETRYABLE" status == PhotoUploadStatus.FailedRetryable.storageValue ||
(status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
fun canDiscardLocalPhotoUpload(status: String, serverPhotoId: String?): Boolean =
serverPhotoId.isNullOrBlank() && status in setOf(
PhotoUploadStatus.FailedRetryable.storageValue,
PhotoUploadStatus.FailedPermanent.storageValue,
)
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean = fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank() status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
@@ -481,6 +618,14 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
) )
} }
fun routeScreenAfterServerRefresh(currentScreen: DriverScreen, route: DriverRouteDto): DriverScreen =
when {
currentScreen in setOf(DriverScreen.StartRoute, DriverScreen.LoadingWeight) &&
route.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA") -> DriverScreen.Detail
currentScreen == DriverScreen.FinishRoute && route.driverLifecycleStatus() == "ZAKOŃCZONA" -> DriverScreen.Detail
else -> currentScreen
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean = fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation !requirePreciseLocation || hasFineLocation
@@ -22,12 +22,15 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository 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.sync.StartupOfflineFallbackLoader
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.AppUpdateDto
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.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
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -39,6 +42,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionOutbox import pl.firmatpp.kierowca.data.upload.RouteActionOutbox
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry
import pl.firmatpp.kierowca.diagnostics.DiagnosticSection import pl.firmatpp.kierowca.diagnostics.DiagnosticSection
@@ -61,6 +65,7 @@ data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true, val loading: Boolean = true,
val refreshing: Boolean = false, val refreshing: Boolean = false,
val startupOfflineAvailable: Boolean = false,
val phone: String = "", val phone: String = "",
val otpCode: String = "", val otpCode: String = "",
val maskedPhone: String = "", val maskedPhone: String = "",
@@ -88,6 +93,7 @@ data class DriverUiState(
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(), val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null, val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(), val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
@@ -129,7 +135,7 @@ data class DriverUiState(
val displayRoutes: List<DriverRouteDto> val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions) get() = projectDriverRoutes(routes, projectionActions)
.sortedWith( .sortedWith(
compareBy<DriverRouteDto> { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 } compareBy<DriverRouteDto> { route -> if (route.driverLifecycleStatus() == "ZAKOŃCZONA") 1 else 0 }
.thenBy { route -> routeDayOrdering(route, selectedDate) }, .thenBy { route -> routeDayOrdering(route, selectedDate) },
) )
@@ -188,6 +194,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val appPreferencesStore = AppPreferencesStore(application) private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application) private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository) private val syncRepository = DriverSyncRepository(application, repository)
private val startupOfflineFallbackLoader = StartupOfflineFallbackLoader()
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application) private val photoUploadOutbox = PhotoUploadOutbox(application)
private val routeActionOutbox = RouteActionOutbox(application) private val routeActionOutbox = RouteActionOutbox(application)
@@ -215,6 +222,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var leaveCalendarRequestGeneration: Long = 0 private var leaveCalendarRequestGeneration: Long = 0
private var routesRequestGeneration: Long = 0 private var routesRequestGeneration: Long = 0
private var routeDetailRequestGeneration: Long = 0 private var routeDetailRequestGeneration: Long = 0
private var routesLoadJob: Job? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
init { init {
@@ -295,6 +303,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
viewModelScope.launch { viewModelScope.launch {
if (repository.hasToken()) { if (repository.hasToken()) {
val offlineAvailable = syncRepository.hasBootstrapCache(_state.value.selectedDate)
_state.update { it.copy(startupOfflineAvailable = offlineAvailable) }
refreshRoutes() refreshRoutes()
} else { } else {
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) } _state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
@@ -338,6 +348,20 @@ 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 useOfflineStartupDataNow() {
val snapshot = _state.value
if (snapshot.screen != DriverScreen.Initializing || !snapshot.startupOfflineAvailable) {
return
}
loadRoutes(
date = snapshot.selectedDate,
showLoading = true,
navigateToRoutes = true,
startupOfflineOnly = true,
)
}
fun onAppForegrounded() { fun onAppForegrounded() {
DriverRuntimeSyncState.foreground = true DriverRuntimeSyncState.foreground = true
liveSyncClient.setForeground(true) liveSyncClient.setForeground(true)
@@ -365,17 +389,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeDayLiveUpdateMessage = null) } _state.update { it.copy(routeDayLiveUpdateMessage = null) }
} }
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { private fun loadRoutes(
date: String?,
showLoading: Boolean,
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean = false,
) {
val generation = ++routesRequestGeneration val generation = ++routesRequestGeneration
viewModelScope.launch { routesLoadJob?.cancel()
routesLoadJob = viewModelScope.launch {
val isStartup = _state.value.screen == DriverScreen.Initializing
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) } _state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching { runCatching {
val cached = if (_state.value.isOnline) { val cached = when {
syncRepository.bootstrap(date) startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
} else {
syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.") ?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
isStartup -> {
startupOfflineFallbackLoader.load(
onlineLoad = { syncRepository.bootstrap(date) },
offlineLoad = { syncRepository.bootstrapFromCache(date) },
)
}
else -> syncRepository.bootstrap(date)
} }
val response = cached.value val response = cached.value
val settings = response.driverAppSettings val settings = response.driverAppSettings
@@ -386,6 +424,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver, driver = response.session.driver,
routes = response.routes.today, routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder, dispatchSheetReminder = response.dispatchSheetReminder,
appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate, selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate, minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate, maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
@@ -495,6 +534,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
photoUploadOutbox.discardConfirmedServerPhotos(response.route) photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update { _state.update {
it.copy( it.copy(
screen = routeScreenAfterServerRefresh(it.screen, response.route),
selectedRoute = response.route, selectedRoute = response.route,
routes = it.routes.map { route -> if (route.id == routeId) response.route else route }, routes = it.routes.map { route -> if (route.id == routeId) response.route else route },
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
@@ -639,6 +679,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun markNotificationPermissionBlocked() {
_state.update {
it.copy(
notificationPermissionDenied = true,
error = "Powiadomienia są włączone w aplikacji, ale Android blokuje ich wyświetlanie. Włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun clearNotificationPermissionWarning() {
_state.update {
it.copy(
notificationPermissionDenied = false,
error = if (it.error?.contains("powiadom", ignoreCase = true) == true) null else it.error,
)
}
}
fun setThemeMode(themeMode: AppThemeMode) { fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) } _state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch { viewModelScope.launch {
@@ -988,7 +1046,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return
val stagePhotos = routePhotosForStage(route.photos, "loading") val stagePhotos = routePhotosForStage(route.photos, "loading")
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" } val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" },
)
val blocker = loadingPhotosSubmitBlocker( val blocker = loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = stageUploads.size,
@@ -1040,17 +1100,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
?: return ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { routeActionOutbox.discard(action.clientActionId) } val interrupted = isWorkerCancellationFailure(action.lastError)
runCatching {
if (interrupted) routeActionOutbox.retry(action.clientActionId)
else routeActionOutbox.discard(action.clientActionId)
}
.onSuccess { .onSuccess {
_state.update { _state.update {
it.copy( it.copy(
routeActions = it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId }, routeActions = if (interrupted) it.routeActions else it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId },
visibleRouteActions = it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId }, visibleRouteActions = if (interrupted) it.visibleRouteActions else it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId },
feedback = if (interrupted) "Ponawiam potwierdzenie z tym samym identyfikatorem operacji." else it.feedback,
error = null, error = null,
) )
} }
if (!interrupted) {
if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute() if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute()
} }
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } } .onFailure { throwable -> _state.update { it.withApiError(throwable) } }
} }
} }
@@ -1085,12 +1152,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement
val weight = if (routeStageRequirementIsVisible(weightRequirement)) { val weight = if (routeStageRequirementIsVisible(weightRequirement)) {
snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() parseRouteWeightTons(snapshot.routeStageWeightText)
} else { } else {
null null
} }
val stagePhotos = routePhotosForStage(route.photos, stage) val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
)
val submitBlocker = if (stage == "loading") { val submitBlocker = if (stage == "loading") {
loadingWeightSubmitBlocker( loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText, weightText = snapshot.routeStageWeightText,
@@ -1136,6 +1205,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds) routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds)
route.copy( route.copy(
status = "W TRAKCIE", status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = weight, loadingWeight = weight,
trackingStatus = "active", trackingStatus = "active",
photos = route.safePhotos(), photos = route.safePhotos(),
@@ -1145,6 +1215,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds) routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds)
route.copy( route.copy(
status = "ZAKOŃCZONA", status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = weight, unloadingWeight = weight,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = Instant.now().toString(), completedAt = Instant.now().toString(),
@@ -1244,13 +1315,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun deleteConfirmedUpload(upload: PhotoUploadEntity) { fun deletePhotoUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId)
if (serverPhotoId == null) {
if (!canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)) return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + upload.clientRequestId, error = null) }
runCatching { photoUploadOutbox.discard(upload.clientRequestId) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - upload.clientRequestId) }
}
return
}
if (!_state.value.isOnline) { if (!_state.value.isOnline) {
_state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") } _state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") }
return return
} }
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) } _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
@@ -1412,7 +1493,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) { private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val activeRoute = routes.firstOrNull { it.status == "W TRAKCIE" || it.trackingStatus == "active" } val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (activeRoute == null) { if (activeRoute == null) {
ActiveRouteTrackingService.stop(getApplication()) ActiveRouteTrackingService.stop(getApplication())
} else { } else {
@@ -1426,7 +1507,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
if (route.status == "W TRAKCIE" || route.trackingStatus == "active") { if (route.driverLifecycleStatus() == "W TRAKCIE") {
if (driver != null) { if (driver != null) {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
@@ -0,0 +1,146 @@
package pl.firmatpp.kierowca.update
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
data class PlayAppUpdateState(
val updateAvailable: Boolean = false,
val availableVersionCode: Int? = null,
val downloaded: Boolean = false,
val checking: Boolean = false,
)
class PlayInAppUpdateController(
private val activity: ComponentActivity,
private val launcher: ActivityResultLauncher<IntentSenderRequest>,
private val onStateChanged: (PlayAppUpdateState) -> Unit,
private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity),
) {
private var state = PlayAppUpdateState()
private val installStateListener = InstallStateUpdatedListener { installState ->
when (installState.installStatus()) {
InstallStatus.DOWNLOADED -> updateState(state.copy(downloaded = true, updateAvailable = true))
InstallStatus.INSTALLED -> updateState(PlayAppUpdateState())
else -> Unit
}
}
init {
appUpdateManager.registerListener(installStateListener)
}
fun checkForUpdate() {
updateState(state.copy(checking = true))
appUpdateManager.appUpdateInfo
.addOnSuccessListener(::handleUpdateInfo)
.addOnFailureListener { updateState(state.copy(checking = false)) }
}
fun startUpdate(required: Boolean, playStoreUrl: String?) {
appUpdateManager.appUpdateInfo
.addOnSuccessListener { info ->
if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE) {
openPlayStore(playStoreUrl)
return@addOnSuccessListener
}
val preferredType = if (required) AppUpdateType.IMMEDIATE else AppUpdateType.FLEXIBLE
val updateType = when {
info.isUpdateTypeAllowed(preferredType) -> preferredType
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> AppUpdateType.FLEXIBLE
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> AppUpdateType.IMMEDIATE
else -> null
}
if (updateType == null || !launchUpdate(info, updateType)) {
openPlayStore(playStoreUrl)
}
}
.addOnFailureListener { openPlayStore(playStoreUrl) }
}
fun completeDownloadedUpdate() {
appUpdateManager.completeUpdate()
}
fun onUpdateFlowResult(resultCode: Int) {
if (resultCode != Activity.RESULT_OK) {
checkForUpdate()
}
}
fun dispose() {
appUpdateManager.unregisterListener(installStateListener)
}
private fun handleUpdateInfo(info: AppUpdateInfo) {
val downloaded = info.installStatus() == InstallStatus.DOWNLOADED
val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE ||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
downloaded
updateState(
PlayAppUpdateState(
updateAvailable = available,
availableVersionCode = info.availableVersionCode().takeIf { it > 0 },
downloaded = downloaded,
checking = false,
),
)
if (
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS &&
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
launchUpdate(info, AppUpdateType.IMMEDIATE)
}
}
private fun launchUpdate(info: AppUpdateInfo, updateType: Int): Boolean =
appUpdateManager.startUpdateFlowForResult(
info,
launcher,
AppUpdateOptions.newBuilder(updateType).build(),
)
private fun openPlayStore(playStoreUrl: String?) {
val packageName = activity.packageName
val candidates = listOf(
Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).setPackage("com.android.vending"),
Intent(
Intent.ACTION_VIEW,
Uri.parse(playStoreUrl?.takeIf { it.isNotBlank() }
?: "https://play.google.com/store/apps/details?id=$packageName"),
),
)
for (intent in candidates) {
try {
activity.startActivity(intent)
return
} catch (_: ActivityNotFoundException) {
// Try the browser fallback.
} catch (_: SecurityException) {
// Try the browser fallback.
}
}
}
private fun updateState(next: PlayAppUpdateState) {
state = next
onStateChanged(next)
}
}
@@ -2,12 +2,27 @@ package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
class ApiErrorMapperTest { class ApiErrorMapperTest {
@Test
fun rethrowsWorkerCancellationInsteadOfMappingItAsApplicationFailure() {
val cancellation = CancellationException("Job was cancelled")
try {
cancellation.rethrowIfCancellation()
fail("CancellationException should be rethrown")
} catch (thrown: CancellationException) {
assertSame(cancellation, thrown)
}
}
@Test @Test
fun mapsNetworkFailuresToRetryableOfflineMessage() { fun mapsNetworkFailuresToRetryableOfflineMessage() {
val error = ApiErrorMapper.map(IOException("timeout")) val error = ApiErrorMapper.map(IOException("timeout"))
@@ -0,0 +1,70 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class StartupOfflineFallbackLoaderTest {
@Test
fun returnsServerDataBeforeTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = { "server" },
offlineLoad = { "cache" },
)
assertEquals("server", result)
}
@Test
fun returnsCachedDataWhenServerExceedsTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { "cache" },
)
assertEquals("cache", result)
}
@Test
fun reportsMissingOfflineDataAfterTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val error = runCatching {
loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { null },
)
}.exceptionOrNull()
assertTrue(error is IOException)
}
@Test
fun doesNotHideAuthenticationFailures() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val authenticationFailure = IllegalStateException("401")
val error = runCatching {
loader.load(
onlineLoad = { throw authenticationFailure },
offlineLoad = { "cache" },
)
}.exceptionOrNull()
assertTrue(error is IllegalStateException)
assertEquals(authenticationFailure.message, error?.message)
}
}
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OfflineOutboxManagerTest {
@Test
fun recognizesLegacyCancellationFailureDetails() {
assertTrue(isWorkerCancellationFailure("Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertTrue(isWorkerCancellationFailure("kotlinx.coroutines.JobCancellationException"))
assertFalse(isWorkerCancellationFailure("HTTP 422: Niepoprawne zdjęcie"))
assertFalse(isWorkerCancellationFailure(null))
}
@Test
fun repairsInterruptedAndRetryablePhotoUploadsButNotRealPermanentFailures() {
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Pending.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Uploading.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Verifying.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedRetryable.storageValue, "timeout"))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "Job was cancelled"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "HTTP 422"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.Cancelled.storageValue, null))
}
@Test
fun repairsInterruptedRouteActionWithItsOriginalIdempotencyKey() {
assertTrue(shouldRepairRouteAction(RouteActionStatus.Pending, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.WaitingForPhotos, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.Syncing, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedRetryable, "timeout"))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Job was cancelled"))
assertTrue(
shouldRepairRouteAction(
RouteActionStatus.FailedPermanent,
"Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.",
),
)
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Brakuje zdjęcia"))
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedConflict, "Kurs już rozpoczęty"))
}
}
@@ -0,0 +1,62 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadReconciliationTest {
@Test
fun serverReceiptIdentityClearsFalseLocalFailure() {
val upload = upload(
clientRequestId = "request-1",
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", upload.contentSha256.uppercase())))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-2", upload.contentSha256)))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")))
}
@Test
fun legacyServerPhotoWithoutHashStillConfirmsMatchingRequestId() {
val upload = upload("request-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", null)))
}
private fun upload(clientRequestId: String, contentSha256: String): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = clientRequestId,
routeId = "10",
localPath = "/tmp/$clientRequestId.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 100,
contentSha256 = contentSha256,
status = PhotoUploadStatus.FailedPermanent.storageValue,
lastError = "Typ: błąd aplikacji\nKomunikat: Job was cancelled",
)
private fun serverPhoto(clientRequestId: String, contentSha256: String?): RoutePhotoDto =
RoutePhotoDto(
id = "42",
routeId = "10",
clientRequestId = clientRequestId,
contentSha256 = contentSha256,
source = "camera",
stage = "loading",
mimeType = "image/jpeg",
size = 100,
url = "https://example.test/photo/42",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
}
@@ -16,7 +16,9 @@ class RouteDisplayMapperTest {
contractName = "Odpady przemyslowe", contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24", contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask", relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA", status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
distanceKm = 91.4, distanceKm = 91.4,
notes = null, notes = null,
truck = null, truck = null,
@@ -29,6 +31,7 @@ class RouteDisplayMapperTest {
assertEquals("Instalacja Odpadowa Slask", display.destination) assertEquals("Instalacja Odpadowa Slask", display.destination)
assertEquals("TPP-ODP-24", display.contractLabel) assertEquals("TPP-ODP-24", display.contractLabel)
assertEquals("09:15", display.time) assertEquals("09:15", display.time)
assertEquals("ZAPLANOWANA", display.status)
assertEquals("91 km", display.distanceLabel) assertEquals("91 km", display.distanceLabel)
} }
} }
@@ -0,0 +1,32 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class StartupSessionPolicyTest {
@Test
fun rotatesStartupMessagesAsWaitingTimeIncreases() {
assertEquals("Sprawdzamy zapisaną sesję", StartupSessionPolicy.message(0).title)
assertEquals("Potwierdzamy logowanie", StartupSessionPolicy.message(3).title)
assertEquals("Pobieramy aktualne kursy", StartupSessionPolicy.message(6).title)
assertEquals("To trwa dłużej niż zwykle", StartupSessionPolicy.message(9).title)
assertEquals("Przygotowujemy tryb offline", StartupSessionPolicy.message(12).title)
}
@Test
fun countsDownToOfflineFallbackWithoutGoingBelowZero() {
assertEquals(15, StartupSessionPolicy.remainingSeconds(0))
assertEquals(7, StartupSessionPolicy.remainingSeconds(8))
assertEquals(0, StartupSessionPolicy.remainingSeconds(15))
assertEquals(0, StartupSessionPolicy.remainingSeconds(30))
assertEquals("Tryb offline najpóźniej za 5 s", StartupSessionPolicy.countdownLabel(10))
assertEquals("Otwieramy zapisane dane offline", StartupSessionPolicy.countdownLabel(15))
}
@Test
fun showsManualOfflineButtonAfterFourSecondsOnlyWhenCacheExists() {
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(3, offlineDataAvailable = true))
assertEquals(true, StartupSessionPolicy.shouldShowOfflineButton(4, offlineDataAvailable = true))
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(10, offlineDataAvailable = false))
}
}
@@ -23,6 +23,7 @@ class DriverLiveSyncClientTest {
reverbEnabled = true, reverbEnabled = true,
reverbAppKey = "app-key", reverbAppKey = "app-key",
reverbWsBaseUrl = "wss://example.test/app", reverbWsBaseUrl = "wss://example.test/app",
reverbOrigin = "https://bootstrap-origin.test",
) )
@Test @Test
@@ -32,6 +33,16 @@ class DriverLiveSyncClientTest {
assertEquals("http://localhost:8080", websocketOrigin("ws://localhost:8080/app/app-key")) assertEquals("http://localhost:8080", websocketOrigin("ws://localhost:8080/app/app-key"))
} }
@Test
fun passesBootstrapOriginToWebSocketFactory() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
assertEquals("https://bootstrap-origin.test", factory.origins.single())
}
@Test @Test
fun reconnectsWithBackoffAfterSocketFailure() = runTest { fun reconnectsWithBackoffAfterSocketFailure() = runTest {
val factory = FakeWebSocketFactory() val factory = FakeWebSocketFactory()
@@ -211,10 +222,12 @@ class DriverLiveSyncClientTest {
private class FakeWebSocketFactory : LiveWebSocketFactory { private class FakeWebSocketFactory : LiveWebSocketFactory {
val sockets = mutableListOf<FakeWebSocket>() val sockets = mutableListOf<FakeWebSocket>()
val origins = mutableListOf<String?>()
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket { override fun newWebSocket(url: String, origin: String?, listener: WebSocketListener): WebSocket {
val socket = FakeWebSocket(listener) val socket = FakeWebSocket(listener)
sockets += socket sockets += socket
origins += origin
return socket return socket
} }
} }
@@ -82,6 +82,108 @@ class DriverUiRulesTest {
assertEquals("", routeDateChipLabel("", today)) assertEquals("", routeDateChipLabel("", today))
} }
@Test
fun buildsNavigationCandidatesFromGeofenceCoordinatesWithFallbacks() {
val candidates = navigationIntentCandidates(
latitude = 50.0619474,
longitude = 19.9368564,
label = "Brama główna",
)
assertEquals(3, candidates.size)
assertEquals("com.google.android.apps.maps", candidates[0].packageName)
assertEquals("google.navigation:q=50.0619474,19.9368564", candidates[0].uri)
assertEquals(null, candidates[1].packageName)
assertEquals(
"geo:0,0?q=50.0619474,19.9368564(Brama%20g%C5%82%C3%B3wna)",
candidates[1].uri,
)
assertEquals(
"https://www.google.com/maps/dir/?api=1&destination=50.0619474,19.9368564",
candidates[2].uri,
)
}
@Test
fun doesNotBuildNavigationCandidatesForMissingOrInvalidCoordinates() {
assertFalse(hasValidNavigationCoordinates(null, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, null))
assertFalse(hasValidNavigationCoordinates(Double.NaN, 19.9368564))
assertFalse(hasValidNavigationCoordinates(91.0, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, 181.0))
assertTrue(hasValidNavigationCoordinates(50.0619474, 19.9368564))
assertTrue(navigationIntentCandidates(null, null, "Cel").isEmpty())
}
@Test
fun showsAnOptionalUpdateWhenGooglePlayOrBackendHasANewerVersion() {
val fromPlay = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = null,
latestVersionName = null,
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val fromBackend = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(false, fromPlay?.required)
assertEquals("Aktualizuj", fromPlay?.actionLabel)
assertEquals("Dostępna jest nowa wersja aplikacji 1.0.61.", fromBackend?.message)
}
@Test
fun requiresAnUpdateOnlyBelowTheConfiguredMinimumVersion() {
val required = appUpdateBannerUi(
currentVersionCode = 111,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val current = appUpdateBannerUi(
currentVersionCode = 114,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(true, required?.required)
assertTrue(required?.message?.contains("nie jest już obsługiwana") == true)
assertEquals(null, current)
}
@Test
fun downloadedUpdateUsesInstallAction() {
val update = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = true,
)
assertEquals("Zainstaluj", update?.actionLabel)
assertEquals(true, update?.downloaded)
assertTrue(update?.message?.contains("gotowa do instalacji") == true)
}
@Test @Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0)) assertEquals(0, inlinePhotoGridRows(0))
@@ -205,8 +307,9 @@ class DriverUiRulesTest {
} }
@Test @Test
fun allowsRetryActionOnlyForRetryableFailedUploads() { fun allowsRetryForRetryableAndLegacyCancelledUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE")) assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertTrue(canRetryPhotoUpload("FAILED_PERMANENT", "Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertFalse(canRetryPhotoUpload("PENDING")) assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING")) assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING")) assertFalse(canRetryPhotoUpload("VERIFYING"))
@@ -215,6 +318,37 @@ class DriverUiRulesTest {
assertFalse(canRetryPhotoUpload("CANCELLED")) assertFalse(canRetryPhotoUpload("CANCELLED"))
} }
@Test
fun permanentUploadDoesNotSatisfyRouteStageAndCanBeDiscarded() {
val failed = upload(status = "FAILED_PERMANENT")
val pending = upload(status = "PENDING").copy(clientRequestId = "pending-upload")
assertEquals(listOf("pending-upload"), routeStageUsableUploads(listOf(failed, pending)).map { it.clientRequestId })
assertFalse(photoUploadCanSatisfyStage("FAILED_PERMANENT"))
assertTrue(canDiscardLocalPhotoUpload("FAILED_PERMANENT", null))
assertFalse(canDiscardLocalPhotoUpload("CONFIRMED", "42"))
}
@Test
fun serverRefreshLeavesObsoleteRouteStageScreen() {
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "W TRAKCIE")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.LoadingWeight, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.FinishRoute, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.StartRoute,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "ZAPLANOWANA")),
)
}
@Test @Test
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() { fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera")) assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
@@ -306,11 +440,20 @@ class DriverUiRulesTest {
fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() { fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() {
assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0)) assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0))
assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1)) assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1))
assertTrue(canSubmitRouteStageForm(weightText = "23400", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0))
} }
@Test
fun parsesRouteWeightsEnteredInTonsOrKilograms() {
assertEquals(23.4, parseRouteWeightTons("23,4") ?: 0.0, 0.0)
assertEquals(23.4, parseRouteWeightTons("23400") ?: 0.0, 0.0)
assertEquals(23.401, parseRouteWeightTons("23400,5") ?: 0.0, 0.0)
assertEquals("Wpisano kilogramy — zapiszemy 23,400 t.", routeWeightInputHelpText("23400"))
}
@Test @Test
fun filtersRoutePhotosByStageWithOtherFallback() { fun filtersRoutePhotosByStageWithOtherFallback() {
val photos = listOf( val photos = listOf(
@@ -356,18 +499,62 @@ class DriverUiRulesTest {
assertEquals(RouteLifecycleAction.Start, action?.action) assertEquals(RouteLifecycleAction.Start, action?.action)
} }
@Test
fun driverFlowIgnoresOperationalStatusFromGeofence() {
val route = route(
status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
)
val steps = routeFlowSteps(route, emptyList())
assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today))
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(DriverScreen.StartRoute, routeScreenAfterServerRefresh(DriverScreen.StartRoute, route))
}
@Test @Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() { fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading"))) val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList()) val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.LocalComplete, steps[0].state) assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
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(RouteFlowStepState.Todo, steps[3].state)
} }
@Test
fun freshInstallLabelsBackendLoadingDocumentationAsServerData() {
val route = route(
status = "ZAPLANOWANA",
loadingWeight = 23.76,
photos = listOf(photo("loading-1", stage = "loading")),
)
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].state)
assertEquals("zapisane na serwerze", steps[1].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun serverWeightDoesNotMarkMissingLoadingPhotoAsComplete() {
val steps = routeFlowSteps(route(status = "ZAPLANOWANA", loadingWeight = 23.76), emptyList())
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].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)
@@ -377,7 +564,8 @@ 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.LocalComplete, steps[2].state) assertEquals(RouteFlowStepState.InProgress, steps[2].state)
assertEquals("w trakcie", steps[2].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[3].state) assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label) assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action) assertEquals(RouteLifecycleAction.Finish, action?.action)
@@ -518,6 +706,8 @@ class DriverUiRulesTest {
private fun route( private fun route(
status: String = "ZAPLANOWANA", status: String = "ZAPLANOWANA",
driverStatus: String? = null,
operationalStatus: String? = null,
loadingWeight: Double? = null, loadingWeight: Double? = null,
unloadingWeight: Double? = null, unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(), photos: List<RoutePhotoDto> = emptyList(),
@@ -536,6 +726,8 @@ class DriverUiRulesTest {
contractCode = "TPP-1", contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja", relationLabel = "Baza -> Instalacja",
status = status, status = status,
driverStatus = driverStatus,
operationalStatus = operationalStatus,
loadingWeight = loadingWeight, loadingWeight = loadingWeight,
unloadingWeight = unloadingWeight, unloadingWeight = unloadingWeight,
distanceKm = 1.0, distanceKm = 1.0,
+3
View File
@@ -17,6 +17,7 @@ room = "2.7.0"
ksp = "2.1.10-1.0.31" ksp = "2.1.10-1.0.31"
playServicesAuth = "21.6.0" playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0" playServicesAuthApiPhone = "18.3.0"
playAppUpdate = "2.1.0"
junit = "4.13.2" junit = "4.13.2"
firebaseBom = "33.7.0" firebaseBom = "33.7.0"
googleServices = "4.4.2" googleServices = "4.4.2"
@@ -52,6 +53,8 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" } play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "playAppUpdate" }
play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "playAppUpdate" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }