Compare commits

..
27 changed files with 951 additions and 82 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 = 111 versionCode = 114
versionName = "1.0.58" 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(
@@ -265,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,
@@ -273,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,
@@ -280,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(),
@@ -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,
) )
} }
@@ -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,6 +13,7 @@ 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.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -72,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
@@ -147,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
@@ -154,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
@@ -166,6 +171,7 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.domain.StartupSessionPolicy 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(
@@ -173,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) }
@@ -319,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,
) )
@@ -336,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,
) )
@@ -353,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,
) )
@@ -396,6 +421,80 @@ 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
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)
}
}
} }
} }
@@ -1991,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(
@@ -2009,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),
@@ -2146,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),
@@ -2168,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,
@@ -2182,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,
@@ -2751,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,
) )
@@ -3252,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,
@@ -3363,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
@@ -3373,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
@@ -3382,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
@@ -3432,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),
@@ -3447,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)
} }
@@ -3558,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)),
@@ -3801,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,
@@ -3951,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) {
@@ -4006,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,
@@ -4044,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)),
@@ -4348,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,
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.ui
import java.io.File import java.io.File
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode 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
@@ -13,7 +14,10 @@ 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
@@ -21,10 +25,89 @@ 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 val routeWeightKilogramInputThreshold = BigDecimal("1000")
private const val routeWeightMaxTons = 999.999 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,
@@ -103,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))}."
} }
@@ -119,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,
@@ -127,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
@@ -297,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
} }
@@ -380,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"
@@ -474,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" }
@@ -485,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()
@@ -514,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
@@ -24,11 +24,13 @@ 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.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
@@ -40,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
@@ -90,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(),
@@ -131,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) },
) )
@@ -420,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,
@@ -529,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,
@@ -1040,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,
@@ -1092,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) } }
} }
} }
@@ -1142,7 +1157,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
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,
@@ -1188,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(),
@@ -1197,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(),
@@ -1296,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) }
@@ -1464,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 {
@@ -1478,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,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)
} }
} }
@@ -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"))
@@ -365,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)
@@ -386,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)
@@ -527,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(),
@@ -545,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" }