Compare commits

..
24 changed files with 1743 additions and 110 deletions
+2 -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 = 117 versionCode = 120
versionName = "1.0.64" versionName = "1.0.67"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+7
View File
@@ -2,12 +2,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<queries>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -20,6 +20,7 @@ class AppPreferencesStore(
private val themeModeKey = stringPreferencesKey("theme_mode") private val themeModeKey = stringPreferencesKey("theme_mode")
private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled") private val routeProgressNotificationEnabledKey = booleanPreferencesKey("route_progress_notification_enabled")
private val routeLocationEnabledKey = booleanPreferencesKey("route_location_enabled")
val themeMode: Flow<AppThemeMode> = dataStore.data val themeMode: Flow<AppThemeMode> = dataStore.data
.map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) } .map { preferences -> AppThemeMode.fromStoredValue(preferences[themeModeKey]) }
@@ -27,6 +28,9 @@ class AppPreferencesStore(
val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data val routeProgressNotificationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true } .map { preferences -> preferences[routeProgressNotificationEnabledKey] ?: true }
val routeLocationEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[routeLocationEnabledKey] ?: false }
suspend fun setThemeMode(themeMode: AppThemeMode) { suspend fun setThemeMode(themeMode: AppThemeMode) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[themeModeKey] = themeMode.storedValue preferences[themeModeKey] = themeMode.storedValue
@@ -38,4 +42,10 @@ class AppPreferencesStore(
preferences[routeProgressNotificationEnabledKey] = enabled preferences[routeProgressNotificationEnabledKey] = enabled
} }
} }
suspend fun setRouteLocationEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[routeLocationEnabledKey] = enabled
}
}
} }
@@ -1,11 +1,19 @@
package pl.firmatpp.kierowca.data.api package pl.firmatpp.kierowca.data.api
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import pl.firmatpp.kierowca.BuildConfig import pl.firmatpp.kierowca.BuildConfig
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
internal const val DRIVER_APP_VERSION_HEADER = "X-Driver-App-Version"
internal fun Request.withDriverAppVersion(appVersion: String): Request =
newBuilder()
.header(DRIVER_APP_VERSION_HEADER, appVersion)
.build()
object ApiFactory { object ApiFactory {
fun mobileDriverApi(): MobileDriverApi { fun mobileDriverApi(): MobileDriverApi {
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
@@ -13,6 +21,9 @@ object ApiFactory {
} }
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(chain.request().withDriverAppVersion(BuildConfig.VERSION_NAME))
}
.addInterceptor(logging) .addInterceptor(logging)
.build() .build()
@@ -72,6 +72,7 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?, val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?, val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?, val requirePreciseLocationForPhotos: Boolean?,
val routeLocationMode: String? = null,
val loadingPhotoRequirement: String? = null, val loadingPhotoRequirement: String? = null,
val loadingWeightRequirement: String? = null, val loadingWeightRequirement: String? = null,
val unloadingPhotoRequirement: String? = null, val unloadingPhotoRequirement: String? = null,
@@ -278,7 +279,9 @@ data class DriverRouteDto(
val driverStatus: String? = null, val driverStatus: String? = null,
val operationalStatus: String? = null, val operationalStatus: String? = null,
val loadingWeight: Double? = null, val loadingWeight: Double? = null,
val loadingNotes: String? = null,
val unloadingWeight: Double? = null, val unloadingWeight: Double? = null,
val unloadingNotes: String? = null,
val trackingStatus: String? = null, val trackingStatus: String? = null,
val arrivedAt: String? = null, val arrivedAt: String? = null,
val distanceKm: Double?, val distanceKm: Double?,
@@ -352,6 +355,7 @@ data class StartRouteBody(
val loadingWeight: Double?, val loadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val loadingNotes: String? = null,
) )
data class FinishRouteBody( data class FinishRouteBody(
@@ -359,6 +363,7 @@ data class FinishRouteBody(
val unloadingWeight: Double?, val unloadingWeight: Double?,
val occurredAt: String, val occurredAt: String,
val photoClientRequestIds: List<String>, val photoClientRequestIds: List<String>,
val unloadingNotes: String? = null,
) )
data class RouteActionResponse( data class RouteActionResponse(
@@ -21,7 +21,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 6, version = 7,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
@@ -202,13 +202,26 @@ abstract class DriverDatabase : RoomDatabase() {
} }
} }
private val migration6To7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE route_actions ADD COLUMN notes TEXT")
}
}
fun get(context: Context): DriverDatabase = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
DriverDatabase::class.java, DriverDatabase::class.java,
"driver-local-outbox.db", "driver-local-outbox.db",
).addMigrations(migration1To2, migration2To3, migration3To4, migration4To5, migration5To6).build().also { instance = it } ).addMigrations(
migration1To2,
migration2To3,
migration3To4,
migration4To5,
migration5To6,
migration6To7,
).build().also { instance = it }
} }
} }
} }
@@ -14,6 +14,61 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity? suspend fun find(clientActionId: String): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'start'
AND createdAtEpochMillis < :beforeEpochMillis
ORDER BY createdAtEpochMillis DESC
LIMIT 1
""",
)
suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity?
@Query(
"""
SELECT * FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis > :afterEpochMillis
AND status != 'CONFIRMED'
ORDER BY createdAtEpochMillis ASC
""",
)
suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List<RouteActionEntity>
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM route_actions
WHERE routeId = :routeId
AND action = 'finish'
AND status = 'CONFIRMED'
AND createdAtEpochMillis > :afterEpochMillis
)
""",
)
suspend fun hasConfirmedFinishAfter(routeId: String, afterEpochMillis: Long): Boolean
@Query(
"""
UPDATE route_actions
SET status = 'CONFIRMED',
lastError = NULL,
updatedAtEpochMillis = :updatedAt
WHERE routeId = :routeId
AND action = 'finish'
AND createdAtEpochMillis < :beforeEpochMillis
AND status != 'CONFIRMED'
""",
)
suspend fun confirmSupersededFinishes(
routeId: String,
beforeEpochMillis: Long,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'") @Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'")
suspend fun allUnsent(): List<RouteActionEntity> suspend fun allUnsent(): List<RouteActionEntity>
@@ -38,4 +38,5 @@ data class RouteActionEntity(
val lastError: String? = null, val lastError: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(), val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(), val updatedAtEpochMillis: Long = System.currentTimeMillis(),
val notes: String? = null,
) )
@@ -43,17 +43,28 @@ class RouteActionOutbox(
enqueueWorker(clientActionId) enqueueWorker(clientActionId)
} }
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun enqueueStart(
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) routeId: String,
loadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
): RouteActionEntity =
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds, notes)
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun enqueueFinish(
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds) routeId: String,
unloadingWeight: Double?,
photoClientRequestIds: List<String>,
notes: String? = null,
): RouteActionEntity =
enqueue(RouteActionType.Finish, routeId, unloadingWeight, photoClientRequestIds, notes)
private suspend fun enqueue( private suspend fun enqueue(
action: String, action: String,
routeId: String, routeId: String,
weight: Double?, weight: Double?,
photoClientRequestIds: List<String>, photoClientRequestIds: List<String>,
notes: String?,
): RouteActionEntity { ): RouteActionEntity {
dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)) dao.pruneConfirmed(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))
val entity = RouteActionEntity( val entity = RouteActionEntity(
@@ -63,6 +74,7 @@ class RouteActionOutbox(
weight = weight, weight = weight,
occurredAt = Instant.now().toString(), occurredAt = Instant.now().toString(),
photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()), photoClientRequestIdsJson = gson.toJson(photoClientRequestIds.distinct()),
notes = notes?.trim()?.takeIf { it.isNotEmpty() },
) )
dao.upsert(entity) dao.upsert(entity)
@@ -8,9 +8,11 @@ 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.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.DriverRouteDto
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
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
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.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -31,6 +33,33 @@ class RouteActionWorker(
if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry()
val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure() val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure()
val action = dao.find(clientActionId) ?: return Result.failure() val action = dao.find(clientActionId) ?: return Result.failure()
if (action.status == RouteActionStatus.Confirmed) return Result.success()
val startPrerequisite = if (action.action == RouteActionType.Finish) {
dao.latestStartBefore(action.routeId, action.createdAtEpochMillis)
} else {
null
}
when (routeActionPrerequisiteState(action, startPrerequisite)) {
RouteActionPrerequisiteState.Waiting -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Pending,
lastError = "Czekam na potwierdzenie rozpoczęcia kursu.",
attemptIncrement = 0,
)
return Result.retry()
}
RouteActionPrerequisiteState.Failed -> {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.FailedConflict,
lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.",
attemptIncrement = 0,
)
return Result.failure()
}
RouteActionPrerequisiteState.Ready -> Unit
}
val photoClientRequestIds = photoClientRequestIds(action) val photoClientRequestIds = photoClientRequestIds(action)
val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) } val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) }
@@ -72,12 +101,25 @@ class RouteActionWorker(
lastError = null, lastError = null,
attemptIncrement = 0, attemptIncrement = 0,
) )
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate) syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate)
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
if (action.action == RouteActionType.Start) {
val outbox = RouteActionOutbox(applicationContext)
dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis)
.forEach { outbox.enqueueWorker(it.clientActionId) }
}
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation() throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
val serverRoute = if (error.kind == ApiErrorKind.Conflict) {
runCatching { repository.route(action.routeId).route }.getOrNull()
} else {
null
}
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
operation = "route_action_worker", operation = "route_action_worker",
@@ -89,8 +131,30 @@ class RouteActionWorker(
"api_error_code" to error.code, "api_error_code" to error.code,
"api_status_code" to error.statusCode, "api_status_code" to error.statusCode,
"retryable" to error.retryable, "retryable" to error.retryable,
"server_route_status" to serverRoute?.driverLifecycleStatus(),
), ),
) )
val supersededByConfirmedFinish = action.action == RouteActionType.Finish &&
dao.hasConfirmedFinishAfter(action.routeId, action.createdAtEpochMillis)
if (serverRoute != null &&
(
routeActionMatchesServer(action, photoClientRequestIds, serverRoute) ||
routeActionWasSuperseded(action, supersededByConfirmedFinish, serverRoute)
)
) {
dao.updateStatus(
clientActionId = clientActionId,
status = RouteActionStatus.Confirmed,
lastError = null,
attemptIncrement = 0,
)
if (action.action == RouteActionType.Finish) {
dao.confirmSupersededFinishes(action.routeId, action.createdAtEpochMillis)
}
syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate)
DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id)
return@getOrElse Result.success()
}
val status = when { val status = when {
error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict
error.retryable -> RouteActionStatus.FailedRetryable error.retryable -> RouteActionStatus.FailedRetryable
@@ -99,7 +163,7 @@ class RouteActionWorker(
dao.updateStatus( dao.updateStatus(
clientActionId = clientActionId, clientActionId = clientActionId,
status = status, status = status,
lastError = error.message, lastError = routeActionConflictMessage(error.message, serverRoute),
attemptIncrement = 0, attemptIncrement = 0,
) )
@@ -116,6 +180,7 @@ class RouteActionWorker(
loadingWeight = action.weight, loadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
loadingNotes = action.notes,
), ),
) )
RouteActionType.Finish -> repository.finishRoute( RouteActionType.Finish -> repository.finishRoute(
@@ -125,6 +190,7 @@ class RouteActionWorker(
unloadingWeight = action.weight, unloadingWeight = action.weight,
occurredAt = action.occurredAt, occurredAt = action.occurredAt,
photoClientRequestIds = photoClientRequestIds, photoClientRequestIds = photoClientRequestIds,
unloadingNotes = action.notes,
), ),
) )
else -> error("Nieznana akcja kursu: ${action.action}") else -> error("Nieznana akcja kursu: ${action.action}")
@@ -144,3 +210,84 @@ class RouteActionWorker(
fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId" fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId"
} }
} }
internal fun routeActionMatchesServer(
action: RouteActionEntity,
photoClientRequestIds: List<String>,
serverRoute: DriverRouteDto,
): Boolean {
val lifecycleMatches = when (action.action) {
RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA")
RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
else -> false
}
if (!lifecycleMatches) return false
val serverWeight = when (action.action) {
RouteActionType.Start -> serverRoute.loadingWeight
RouteActionType.Finish -> serverRoute.unloadingWeight
else -> null
}
val serverNotes = when (action.action) {
RouteActionType.Start -> serverRoute.loadingNotes
RouteActionType.Finish -> serverRoute.unloadingNotes
else -> null
}
val weightMatches = when {
action.weight == null && serverWeight == null -> true
action.weight == null || serverWeight == null -> false
else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005
}
val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes()
val serverPhotoRequestIds = serverRoute.photos
.mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) }
.toSet()
val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains)
return weightMatches && notesMatch && photosMatch
}
internal fun routeActionWasSuperseded(
action: RouteActionEntity,
hasConfirmedFinishAfter: Boolean,
serverRoute: DriverRouteDto,
): Boolean =
action.action == RouteActionType.Finish &&
hasConfirmedFinishAfter &&
serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA"
internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String {
val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message
val operationalStatus = serverRoute.operationalStatus
?.takeIf(String::isNotBlank)
?.let { ", status operacyjny: $it" }
.orEmpty()
return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane."
}
private fun String?.normalizedRouteActionNotes(): String? =
this?.trim()?.takeIf(String::isNotEmpty)
internal enum class RouteActionPrerequisiteState {
Ready,
Waiting,
Failed,
}
internal fun routeActionPrerequisiteState(
action: RouteActionEntity,
precedingStart: RouteActionEntity?,
): RouteActionPrerequisiteState {
if (action.action != RouteActionType.Finish || precedingStart == null) {
return RouteActionPrerequisiteState.Ready
}
return when (precedingStart.status) {
RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready
RouteActionStatus.FailedConflict,
RouteActionStatus.FailedPermanent,
-> RouteActionPrerequisiteState.Failed
else -> RouteActionPrerequisiteState.Waiting
}
}
@@ -54,6 +54,11 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_STOP && !hasLocationPermission()) {
stopSelf()
return START_NOT_STICKY
}
when (intent?.action) { when (intent?.action) {
ACTION_STOP -> { ACTION_STOP -> {
val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) val requestedRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
@@ -69,7 +74,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
routeId = id routeId = id
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
} }
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -97,7 +104,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
?: totalDistanceMeters ?: totalDistanceMeters
progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled progressEnabled = intent?.getBooleanExtra(EXTRA_PROGRESS_ENABLED, true) ?: progressEnabled
if (routeChanged) arrived = false if (routeChanged) arrived = false
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) {
return START_NOT_STICKY
}
if (!arrived) requestLocationUpdates() if (!arrived) requestLocationUpdates()
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -113,9 +122,9 @@ class ActiveRouteTrackingService : Service(), LocationListener {
if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) { if (!arrived && remaining != null && remaining <= arrivalRadiusMeters(location)) {
arrived = true arrived = true
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} else { } else {
startForegroundCompat(buildNotification()) if (!startForegroundCompat(buildNotification())) return
} }
val (battery, charging) = batterySnapshot() val (battery, charging) = batterySnapshot()
@@ -204,11 +213,25 @@ class ActiveRouteTrackingService : Service(), LocationListener {
return builder.build() return builder.build()
} }
private fun startForegroundCompat(notification: Notification) { private fun startForegroundCompat(notification: Notification): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && hasLocationPermission()) { if (!hasLocationPermission()) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) stopSelf()
} else { return false
startForeground(NOTIFICATION_ID, notification) }
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
} else {
startForeground(NOTIFICATION_ID, notification)
}
true
} catch (_: SecurityException) {
stopSelf()
false
} catch (_: IllegalStateException) {
stopSelf()
false
} }
} }
@@ -220,7 +243,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
"Aktywny kurs", "Aktywny kurs",
NotificationManager.IMPORTANCE_LOW, NotificationManager.IMPORTANCE_LOW,
).apply { ).apply {
description = "Postęp aktywnego kursu i lokalizacja w tle" description = "Informacje o aktywnym kursie"
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
@@ -299,6 +322,8 @@ class ActiveRouteTrackingService : Service(), LocationListener {
private const val ACTIVE_ROUTE_ID = "active_route_id" private const val ACTIVE_ROUTE_ID = "active_route_id"
fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) { fun start(context: Context, route: DriverRouteDto, driver: DriverDto, progressEnabled: Boolean = true) {
if (!hasLocationPermission(context)) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_START action = ACTION_START
putExtra(EXTRA_ROUTE_ID, route.id) putExtra(EXTRA_ROUTE_ID, route.id)
@@ -309,7 +334,7 @@ class ActiveRouteTrackingService : Service(), LocationListener {
route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) } route.destinationNavigation?.longitude?.let { putExtra(EXTRA_DESTINATION_LON, it) }
route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) } route.distanceKm?.takeIf { it > 0.0 }?.let { putExtra(EXTRA_TOTAL_DISTANCE_METERS, it * 1000.0) }
} }
ContextCompat.startForegroundService(context, intent) runCatching { ContextCompat.startForegroundService(context, intent) }
} }
fun stop(context: Context, routeId: String? = null) { fun stop(context: Context, routeId: String? = null) {
@@ -325,11 +350,20 @@ class ActiveRouteTrackingService : Service(), LocationListener {
} }
fun markArrived(context: Context, routeId: String) { fun markArrived(context: Context, routeId: String) {
if (!hasLocationPermission(context)) return
val activeRouteId = context.getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.getString(ACTIVE_ROUTE_ID, null)
if (activeRouteId != routeId) return
val intent = Intent(context, ActiveRouteTrackingService::class.java).apply { val intent = Intent(context, ActiveRouteTrackingService::class.java).apply {
action = ACTION_MARK_ARRIVED action = ACTION_MARK_ARRIVED
putExtra(EXTRA_ROUTE_ID, routeId) putExtra(EXTRA_ROUTE_ID, routeId)
} }
ContextCompat.startForegroundService(context, intent) runCatching { ContextCompat.startForegroundService(context, intent) }
} }
private fun hasLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
} }
} }
@@ -8,8 +8,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Location
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
@@ -68,6 +66,7 @@ import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.LocationOff
import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.LocalShipping import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Navigation import androidx.compose.material.icons.outlined.Navigation
@@ -107,6 +106,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -148,6 +148,7 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
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.BuildConfig
@@ -173,6 +174,12 @@ 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 import pl.firmatpp.kierowca.update.PlayAppUpdateState
private enum class RouteLocationPermissionPurpose {
StartRequiredRoute,
EnableOptionalTracking,
RestoreRequiredTracking,
}
@Composable @Composable
fun DriverApp( fun DriverApp(
viewModel: DriverViewModel, viewModel: DriverViewModel,
@@ -203,6 +210,23 @@ fun DriverApp(
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) }
val activeTrackingRouteId = state.displaySelectedRoute
?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" }
?.id
?: state.displayRoutes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }?.id
var locationPermissionGranted by remember(context) {
mutableStateOf(hasLocationPermission(context))
}
var showLocationPermissionDialog by remember { mutableStateOf(false) }
var showLocationPermissionDeniedDialog by remember { mutableStateOf(false) }
var locationPermissionPurpose by remember {
mutableStateOf<RouteLocationPermissionPurpose?>(null)
}
val warnAboutRequiredLocation = shouldWarnAboutRequiredRouteLocation(
mode = state.routeLocationMode,
hasActiveRoute = activeTrackingRouteId != null,
hasLocationPermission = locationPermissionGranted,
)
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { granted -> ) { granted ->
@@ -212,6 +236,75 @@ fun DriverApp(
viewModel.markNotificationPermissionDenied() viewModel.markNotificationPermissionDenied()
} }
} }
val locationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { grants ->
val granted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true ||
hasLocationPermission(context)
locationPermissionGranted = granted
if (granted) {
showLocationPermissionDialog = false
showLocationPermissionDeniedDialog = false
if (locationPermissionPurpose == RouteLocationPermissionPurpose.EnableOptionalTracking) {
viewModel.setRouteLocationEnabled(true)
}
viewModel.onLocationPermissionGranted()
if (locationPermissionPurpose == RouteLocationPermissionPurpose.StartRequiredRoute) {
viewModel.openStartRoute()
}
} else {
showLocationPermissionDeniedDialog = true
}
locationPermissionPurpose = null
}
val dismissLocationPermissionDialog = {
showLocationPermissionDialog = false
locationPermissionPurpose = null
}
val requestLocationPermission = {
showLocationPermissionDialog = false
showLocationPermissionDeniedDialog = false
if (locationPermissionPurpose == RouteLocationPermissionPurpose.EnableOptionalTracking) {
viewModel.setRouteLocationEnabled(true)
}
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
val requestLocationForRequiredRouteStart = {
if (locationPermissionGranted) {
viewModel.openStartRoute()
} else {
locationPermissionPurpose = RouteLocationPermissionPurpose.StartRequiredRoute
showLocationPermissionDialog = true
}
}
val changeOptionalRouteLocation = { enabled: Boolean ->
if (!enabled) {
viewModel.setRouteLocationEnabled(false)
} else if (locationPermissionGranted) {
viewModel.setRouteLocationEnabled(true)
viewModel.onLocationPermissionGranted()
} else {
locationPermissionPurpose = RouteLocationPermissionPurpose.EnableOptionalTracking
showLocationPermissionDialog = true
}
}
val requestRequiredRouteLocation = {
locationPermissionPurpose = RouteLocationPermissionPurpose.RestoreRequiredTracking
showLocationPermissionDialog = true
}
val openStartRoute = {
if (routeStartRequiresLocationPermission(state.routeLocationMode, locationPermissionGranted)) {
requestLocationForRequiredRouteStart()
} else {
viewModel.openStartRoute()
}
}
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) { BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
viewModel.back() viewModel.back()
@@ -276,6 +369,20 @@ fun DriverApp(
viewModel.markNotificationPermissionBlocked() viewModel.markNotificationPermissionBlocked()
} }
} }
LaunchedEffect(
appInForeground,
activeTrackingRouteId,
state.routeLocationMode,
state.routeLocationEnabled,
) {
if (!appInForeground) return@LaunchedEffect
val granted = hasLocationPermission(context)
if (granted && activeTrackingRouteId != null) {
viewModel.onLocationPermissionGranted()
}
locationPermissionGranted = granted
}
Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) { Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) {
when (state.screen) { when (state.screen) {
@@ -322,6 +429,9 @@ fun DriverApp(
}, },
onOpenNotificationSettings = { openAppNotificationSettings(context) }, onOpenNotificationSettings = { openAppNotificationSettings(context) },
onThemeModeChanged = viewModel::setThemeMode, onThemeModeChanged = viewModel::setThemeMode,
locationPermissionGranted = locationPermissionGranted,
onRouteLocationChanged = changeOptionalRouteLocation,
onRequestRequiredLocation = requestRequiredRouteLocation,
onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled, onRouteProgressNotificationChanged = viewModel::setRouteProgressNotificationEnabled,
onDiagnostics = viewModel::openDiagnostics, onDiagnostics = viewModel::openDiagnostics,
) )
@@ -331,13 +441,15 @@ fun DriverApp(
onRefresh = viewModel::refreshDiagnostics, onRefresh = viewModel::refreshDiagnostics,
) )
DriverScreen.Detail -> DetailScreen( DriverScreen.Detail -> DetailScreen(
state, state = state,
viewModel::back, locationPermissionGranted = locationPermissionGranted,
viewModel::openPhoto, onBack = viewModel::back,
viewModel::refreshSelectedRoute, onPhoto = viewModel::openPhoto,
viewModel::openStartRoute, onRefresh = viewModel::refreshSelectedRoute,
viewModel::openFinishRoute, onStartRoute = openStartRoute,
viewModel::correctFailedRouteAction, onFinishRoute = viewModel::openFinishRoute,
onRetryRouteAction = viewModel::retryConflictedRouteAction,
onCorrectRouteAction = viewModel::correctFailedRouteAction,
) )
DriverScreen.StartRoute -> RouteStageScreen( DriverScreen.StartRoute -> RouteStageScreen(
state = state, state = state,
@@ -346,10 +458,12 @@ fun DriverApp(
weightLabel = "Tonaż załadunku", weightLabel = "Tonaż załadunku",
submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek", submitLabel = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) "Dalej: tonaż" else "Potwierdź załadunek",
showWeightInput = false, showWeightInput = false,
showNotesInput = !routeStageRequirementIsVisible(state.loadingWeightRequirement),
showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement), showPhotoActions = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement), showPhotoGrid = routeStageRequirementIsVisible(state.loadingPhotoRequirement),
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
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::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
@@ -363,10 +477,12 @@ fun DriverApp(
weightLabel = "Tonaż załadunku", weightLabel = "Tonaż załadunku",
submitLabel = "Potwierdź załadunek", submitLabel = "Potwierdź załadunek",
showWeightInput = routeStageRequirementIsVisible(state.loadingWeightRequirement), showWeightInput = routeStageRequirementIsVisible(state.loadingWeightRequirement),
showNotesInput = true,
showPhotoActions = false, showPhotoActions = false,
showPhotoGrid = false, showPhotoGrid = false,
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
onUpload = { _, _, _ -> }, onUpload = { _, _, _ -> },
onPhoto = viewModel::openPhoto, onPhoto = viewModel::openPhoto,
onDeleteUpload = viewModel::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
@@ -380,10 +496,12 @@ fun DriverApp(
weightLabel = "Tonaż rozładunku", weightLabel = "Tonaż rozładunku",
submitLabel = "Potwierdź rozładunek", submitLabel = "Potwierdź rozładunek",
showWeightInput = routeStageRequirementIsVisible(state.unloadingWeightRequirement), showWeightInput = routeStageRequirementIsVisible(state.unloadingWeightRequirement),
showNotesInput = true,
showPhotoActions = routeStageRequirementIsVisible(state.unloadingPhotoRequirement), showPhotoActions = routeStageRequirementIsVisible(state.unloadingPhotoRequirement),
showPhotoGrid = routeStageRequirementIsVisible(state.unloadingPhotoRequirement), showPhotoGrid = routeStageRequirementIsVisible(state.unloadingPhotoRequirement),
onBack = viewModel::back, onBack = viewModel::back,
onWeightChange = viewModel::updateRouteStageWeight, onWeightChange = viewModel::updateRouteStageWeight,
onNotesChange = viewModel::updateRouteStageNotes,
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::deletePhotoUpload, onDeleteUpload = viewModel::deletePhotoUpload,
@@ -430,18 +548,124 @@ fun DriverApp(
} }
} }
if (updateUi != null && !updateUi.required) { if (warnAboutRequiredLocation || updateUi != null && !updateUi.required) {
AppUpdateBanner( Column(
update = updateUi,
onUpdate = runUpdate,
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
) verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (warnAboutRequiredLocation) {
LocationPermissionWarningBanner(
onResolve = requestRequiredRouteLocation,
)
}
if (updateUi != null && !updateUi.required) {
AppUpdateBanner(
update = updateUi,
onUpdate = runUpdate,
)
}
}
} }
} }
if (showLocationPermissionDialog && updateUi?.required != true) {
val required = locationPermissionPurpose != RouteLocationPermissionPurpose.EnableOptionalTracking
AlertDialog(
onDismissRequest = dismissLocationPermissionDialog,
icon = {
Icon(
Icons.Outlined.LocationOn,
contentDescription = null,
tint = TppTheme.colors.forest,
)
},
title = {
Text(
if (required) "Lokalizacja jest wymagana" else "Lokalizacja podczas kursu",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
text = {
Text(
if (required) {
if (locationPermissionPurpose == RouteLocationPermissionPurpose.StartRequiredRoute) {
"Aby rozpocząć kurs, zezwól aplikacji na dostęp do lokalizacji."
} else {
"Lokalizacja jest wymagana. Zezwól aplikacji na dostęp w ustawieniach systemowych."
}
} else {
"Włącz lokalizację dla aktywnego kursu. Android poprosi teraz o zgodę systemową."
},
color = TppTheme.colors.muted,
)
},
confirmButton = {
Button(
onClick = requestLocationPermission,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
) {
Text("Zezwól", color = Color.White, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = dismissLocationPermissionDialog) {
Text("Anuluj", color = TppTheme.colors.muted)
}
},
)
}
if (showLocationPermissionDeniedDialog && updateUi?.required != true) {
val required = state.routeLocationMode == RouteLocationMode.Required
AlertDialog(
onDismissRequest = { showLocationPermissionDeniedDialog = false },
icon = {
Icon(
Icons.Outlined.LocationOff,
contentDescription = null,
tint = TppTheme.colors.error,
)
},
title = {
Text(
if (required) "Lokalizacja jest wymagana" else "Brak dostępu do lokalizacji",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
text = {
Text(
if (required) {
"Bez zgody na lokalizację nie można rozpocząć kursu. Włącz ją w ustawieniach aplikacji."
} else {
"Opcja nie została włączona. Możesz zezwolić na lokalizację później w ustawieniach aplikacji."
},
color = TppTheme.colors.muted,
)
},
confirmButton = {
Button(
onClick = {
showLocationPermissionDeniedDialog = false
openAppSettings(context)
},
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
) {
Text("Otwórz ustawienia", color = Color.White, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showLocationPermissionDeniedDialog = false }) {
Text("Anuluj", color = TppTheme.colors.muted)
}
},
)
}
if (updateUi?.required == true) { if (updateUi?.required == true) {
AlertDialog( AlertDialog(
onDismissRequest = {}, onDismissRequest = {},
@@ -932,6 +1156,7 @@ private fun RoutesScreen(
onDismissLiveUpdate: () -> Unit, onDismissLiveUpdate: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -944,7 +1169,9 @@ private fun RoutesScreen(
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
) )
) { ) {
onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context)) coroutineScope.launch {
onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context))
}
} }
cameraUri = null cameraUri = null
} }
@@ -2406,6 +2633,9 @@ private fun ProfileScreen(
onNewRouteNotificationsChanged: (Boolean) -> Unit, onNewRouteNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
onThemeModeChanged: (AppThemeMode) -> Unit, onThemeModeChanged: (AppThemeMode) -> Unit,
locationPermissionGranted: Boolean,
onRouteLocationChanged: (Boolean) -> Unit,
onRequestRequiredLocation: () -> Unit,
onRouteProgressNotificationChanged: (Boolean) -> Unit, onRouteProgressNotificationChanged: (Boolean) -> Unit,
onDiagnostics: () -> Unit, onDiagnostics: () -> Unit,
) { ) {
@@ -2509,30 +2739,99 @@ private fun ProfileScreen(
Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold) Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold)
} }
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) if (state.routeLocationMode != RouteLocationMode.Disabled) {
Row( HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
Modifier.fillMaxWidth(), if (state.routeLocationMode == RouteLocationMode.Optional) {
horizontalArrangement = Arrangement.spacedBy(14.dp), Row(
verticalAlignment = Alignment.CenterVertically, Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
"Lokalizacja podczas kursu",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = TppTheme.colors.ink,
)
Text(
if (state.routeLocationEnabled && locationPermissionGranted) {
"Włączona dla aktywnego kursu."
} else {
"Wyłączona. Możesz ją włączyć i udzielić zgody systemowej."
},
style = MaterialTheme.typography.bodySmall,
color = TppTheme.colors.muted,
)
}
Switch(
checked = state.routeLocationEnabled && locationPermissionGranted,
onCheckedChange = onRouteLocationChanged,
enabled = !state.loading,
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
"Lokalizacja podczas kursu",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = TppTheme.colors.ink,
)
Text(
if (locationPermissionGranted) {
"Lokalizacja jest wymagana i ma dostęp systemowy."
} else {
"Lokalizacja jest wymagana. Zezwól na dostęp przed rozpoczęciem kursu."
},
style = MaterialTheme.typography.bodySmall,
color = if (locationPermissionGranted) TppTheme.colors.muted else TppTheme.colors.error,
)
if (!locationPermissionGranted) {
Button(
onClick = onRequestRequiredLocation,
modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy),
shape = MaterialTheme.shapes.small,
) {
Text("Zezwól na lokalizację", fontWeight = FontWeight.Bold)
}
}
}
}
}
if (
shouldTrackRouteLocation(
state.routeLocationMode,
state.routeLocationEnabled,
locationPermissionGranted,
)
) { ) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
Text( Row(
"Pasek postępu aktywnego kursu", Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyLarge, horizontalArrangement = Arrangement.spacedBy(14.dp),
fontWeight = FontWeight.SemiBold, verticalAlignment = Alignment.CenterVertically,
color = TppTheme.colors.ink, ) {
) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text( Text(
if (state.routeProgressNotificationEnabled) "Powiadomienie pokazuje dystans do celu." else "Powiadomienie zostaje minimalne: Kurs aktywny.", "Pasek postępu aktywnego kursu",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyLarge,
color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold,
color = TppTheme.colors.ink,
)
Text(
if (state.routeProgressNotificationEnabled) "Powiadomienie pokazuje dystans do celu." else "Powiadomienie zostaje minimalne: Kurs aktywny.",
style = MaterialTheme.typography.bodySmall,
color = TppTheme.colors.muted,
)
}
Switch(
checked = state.routeProgressNotificationEnabled,
onCheckedChange = onRouteProgressNotificationChanged,
enabled = !state.loading,
) )
} }
Switch(
checked = state.routeProgressNotificationEnabled,
onCheckedChange = onRouteProgressNotificationChanged,
enabled = !state.loading,
)
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
Row( Row(
@@ -2867,10 +3166,12 @@ private fun RouteStageScreen(
weightLabel: String, weightLabel: String,
submitLabel: String, submitLabel: String,
showWeightInput: Boolean = true, showWeightInput: Boolean = true,
showNotesInput: Boolean = true,
showPhotoActions: Boolean = true, showPhotoActions: Boolean = true,
showPhotoGrid: Boolean = true, showPhotoGrid: Boolean = true,
onBack: () -> Unit, onBack: () -> Unit,
onWeightChange: (String) -> Unit, onWeightChange: (String) -> Unit,
onNotesChange: (String) -> Unit,
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit, onDeleteUpload: (PhotoUploadEntity) -> Unit,
@@ -2878,8 +3179,10 @@ private fun RouteStageScreen(
onSubmit: () -> Unit, onSubmit: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) }
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 }
@@ -2908,6 +3211,11 @@ private fun RouteStageScreen(
photoRequirement = photoRequirement, photoRequirement = photoRequirement,
) )
} }
val effectiveSubmitBlocker = if (voiceDictationActive) {
"Zakończ dyktowanie przed zatwierdzeniem."
} else {
submitBlocker
}
val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) } val weightBlocker = submitBlocker?.takeIf { it.contains("wag", ignoreCase = true) || it.contains("popraw", ignoreCase = true) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
@@ -2920,7 +3228,9 @@ private fun RouteStageScreen(
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION), hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
) )
) { ) {
onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) coroutineScope.launch {
onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
}
} }
cameraUri = null cameraUri = null
} }
@@ -2947,7 +3257,7 @@ private fun RouteStageScreen(
bottomBar = { bottomBar = {
RouteStageSubmitBar( RouteStageSubmitBar(
submitLabel = submitLabel, submitLabel = submitLabel,
submitBlocker = submitBlocker, submitBlocker = effectiveSubmitBlocker,
onSubmit = onSubmit, onSubmit = onSubmit,
) )
}, },
@@ -3004,6 +3314,18 @@ private fun RouteStageScreen(
) )
} }
} }
if (showNotesInput) {
item {
VoiceNotesField(
value = state.routeStageNotesText,
onValueChange = onNotesChange,
stageLabel = if (stage == "loading") "załadunku" else "rozładunku",
enabled = !state.loading,
onListeningChanged = { voiceDictationActive = it },
modifier = Modifier.fillMaxWidth(),
)
}
}
if (showPhotoActions) { if (showPhotoActions) {
item { item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -3240,11 +3562,13 @@ private fun RouteStageSubmitBar(
@Composable @Composable
private fun DetailScreen( private fun DetailScreen(
state: DriverUiState, state: DriverUiState,
locationPermissionGranted: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onStartRoute: () -> Unit, onStartRoute: () -> Unit,
onFinishRoute: () -> Unit, onFinishRoute: () -> Unit,
onRetryRouteAction: () -> Unit,
onCorrectRouteAction: () -> Unit, onCorrectRouteAction: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -3276,11 +3600,17 @@ private fun DetailScreen(
feedback = state.feedback, feedback = state.feedback,
routeActions = projection.visibleActions, routeActions = projection.visibleActions,
routeProgressNotificationEnabled = state.routeProgressNotificationEnabled, routeProgressNotificationEnabled = state.routeProgressNotificationEnabled,
routeLocationActive = shouldTrackRouteLocation(
state.routeLocationMode,
state.routeLocationEnabled,
locationPermissionGranted,
),
loadingPhotoRequirement = state.loadingPhotoRequirement, loadingPhotoRequirement = state.loadingPhotoRequirement,
loadingWeightRequirement = state.loadingWeightRequirement, loadingWeightRequirement = state.loadingWeightRequirement,
onRefresh = onRefresh, onRefresh = onRefresh,
onStart = onStartRoute, onStart = onStartRoute,
onFinish = onFinishRoute, onFinish = onFinishRoute,
onRetry = onRetryRouteAction,
onCorrect = onCorrectRouteAction, onCorrect = onCorrectRouteAction,
) )
} }
@@ -3340,11 +3670,13 @@ private fun RouteLifecycleSection(
feedback: String?, feedback: String?,
routeActions: List<RouteActionEntity>, routeActions: List<RouteActionEntity>,
routeProgressNotificationEnabled: Boolean, routeProgressNotificationEnabled: Boolean,
routeLocationActive: Boolean,
loadingPhotoRequirement: String, loadingPhotoRequirement: String,
loadingWeightRequirement: String, loadingWeightRequirement: String,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onStart: () -> Unit, onStart: () -> Unit,
onFinish: () -> Unit, onFinish: () -> Unit,
onRetry: () -> Unit,
onCorrect: () -> Unit, onCorrect: () -> Unit,
) { ) {
val steps = routeFlowSteps(route, routeActions, loadingPhotoRequirement, loadingWeightRequirement) val steps = routeFlowSteps(route, routeActions, loadingPhotoRequirement, loadingWeightRequirement)
@@ -3380,12 +3712,20 @@ private fun RouteLifecycleSection(
container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel, container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel,
outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline, outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline,
color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted, color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted,
actionLabel = if (callout.canCorrect) "Popraw dane" else "Odśwież dane", actionLabel = when {
onAction = if (callout.canCorrect) onCorrect else onRefresh, callout.canCorrect -> "Popraw dane"
callout.isConflict -> "Ponów wysyłanie"
else -> "Odśwież dane"
},
onAction = when {
callout.canCorrect -> onCorrect
callout.isConflict -> onRetry
else -> onRefresh
},
) )
} }
if (route.driverLifecycleStatus() == "W TRAKCIE" && !routeProgressNotificationEnabled) { if (route.driverLifecycleStatus() == "W TRAKCIE" && routeLocationActive && !routeProgressNotificationEnabled) {
Text( Text(
"Powiadomienie działa w trybie minimalnym.", "Powiadomienie działa w trybie minimalnym.",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
@@ -3768,6 +4108,7 @@ private fun CargoDocumentationSection(
RouteStageDocumentationBlock( RouteStageDocumentationBlock(
title = "Załadunek", title = "Załadunek",
weight = route.loadingWeight, weight = route.loadingWeight,
notes = route.loadingNotes,
photos = loadingPhotos, photos = loadingPhotos,
uploads = loadingUploads, uploads = loadingUploads,
deletingPhotoIds = deletingPhotoIds, deletingPhotoIds = deletingPhotoIds,
@@ -3778,6 +4119,7 @@ private fun CargoDocumentationSection(
RouteStageDocumentationBlock( RouteStageDocumentationBlock(
title = "Rozładunek", title = "Rozładunek",
weight = route.unloadingWeight, weight = route.unloadingWeight,
notes = route.unloadingNotes,
photos = unloadingPhotos, photos = unloadingPhotos,
uploads = unloadingUploads, uploads = unloadingUploads,
deletingPhotoIds = deletingPhotoIds, deletingPhotoIds = deletingPhotoIds,
@@ -3789,6 +4131,7 @@ private fun CargoDocumentationSection(
RouteStageDocumentationBlock( RouteStageDocumentationBlock(
title = "Pozostałe", title = "Pozostałe",
weight = null, weight = null,
notes = null,
photos = otherPhotos, photos = otherPhotos,
uploads = otherUploads, uploads = otherUploads,
deletingPhotoIds = deletingPhotoIds, deletingPhotoIds = deletingPhotoIds,
@@ -3804,6 +4147,7 @@ private fun CargoDocumentationSection(
private fun RouteStageDocumentationBlock( private fun RouteStageDocumentationBlock(
title: String, title: String,
weight: Double?, weight: Double?,
notes: String?,
photos: List<RoutePhotoDto>, photos: List<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>, uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>, deletingPhotoIds: Set<String>,
@@ -3837,6 +4181,38 @@ private fun RouteStageDocumentationBlock(
} }
} }
} }
if (!notes.isNullOrBlank()) {
Column(
Modifier
.fillMaxWidth()
.background(TppTheme.colors.panel, RoundedCornerShape(4.dp))
.border(1.dp, TppTheme.colors.outline.copy(alpha = 0.55f), RoundedCornerShape(4.dp))
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
Text(
"UWAGA KIEROWCY",
color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelSmall,
)
Text(
notes,
color = TppTheme.colors.ink,
fontWeight = FontWeight.SemiBold,
)
if (weightPending) {
Text(
"uwaga czeka na wysłanie",
color = TppTheme.colors.containerGreen,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
if (count == 0) { if (count == 0) {
Box( Box(
Modifier Modifier
@@ -4444,6 +4820,50 @@ private fun ErrorText(error: String?) {
if (!error.isNullOrBlank()) Text(error, color = TppTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp)) if (!error.isNullOrBlank()) Text(error, color = TppTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
} }
@Composable
private fun LocationPermissionWarningBanner(onResolve: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer),
border = BorderStroke(1.dp, TppTheme.colors.warningOutline),
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.LocationOff,
contentDescription = null,
tint = Color(0xFF7A5100),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
"Lokalizacja jest wymagana",
color = Color(0xFF5F4200),
fontWeight = FontWeight.Bold,
)
Text(
"Zezwól na lokalizację, aby korzystać z aktywnego kursu.",
color = Color(0xFF5F4200),
style = MaterialTheme.typography.bodySmall,
)
}
TextButton(onClick = onResolve) {
Text(
"Zezwól",
color = TppTheme.colors.forest,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable @Composable
private fun OfflineStaleBanner(state: DriverUiState) { private fun OfflineStaleBanner(state: DriverUiState) {
val syncLabel = state.lastServerSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji" val syncLabel = state.lastServerSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji"
@@ -4529,8 +4949,8 @@ private fun cameraCapturePermissions(context: Context, requirePreciseLocation: B
} }
}.toTypedArray() }.toTypedArray()
private fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata { private suspend fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata {
val location = bestLastKnownLocation(context) val location = currentPhotoLocation(context)
return PhotoUploadMetadata( return PhotoUploadMetadata(
takenAt = Instant.now().toString(), takenAt = Instant.now().toString(),
@@ -4555,27 +4975,6 @@ private fun openAppSettings(context: Context) {
context.startActivity(intent) context.startActivity(intent)
} }
private fun bestLastKnownLocation(context: Context): Location? {
if (!hasLocationPermission(context)) return null
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
return providers.mapNotNull { provider ->
runCatching {
if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) {
manager.getLastKnownLocation(provider)
} else {
null
}
}.getOrNull()
}.maxByOrNull { it.time }
}
@Composable @Composable
private fun SmsUserConsentEffect(enabled: Boolean, requestId: Long, onCode: (Long, String) -> Unit) { private fun SmsUserConsentEffect(enabled: Boolean, requestId: Long, onCode: (Long, String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent, RouteActionStatus.FailedPermanent,
) )
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
private val routeActionLifecycleProjectionStatuses = setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
RouteActionStatus.Confirmed,
)
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute { fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses } .filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses }
var projectedRoute = route var projectedRoute = route
var loadingWeightPending = false var loadingWeightPending = false
var unloadingWeightPending = false var unloadingWeightPending = false
@@ -39,23 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
when (action.action) { when (action.action) {
RouteActionType.Start -> { RouteActionType.Start -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
trackingStatus = "active", loadingNotes = action.notes,
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
) )
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
trackingStatus = "active",
)
}
loadingWeightPending = action.status != RouteActionStatus.Confirmed loadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight, unloadingWeight = action.weight,
trackingStatus = "finished", unloadingNotes = action.notes,
completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
) )
if (action.status in routeActionLifecycleProjectionStatuses) {
projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
trackingStatus = "finished",
completedAt = action.occurredAt,
)
}
unloadingWeightPending = action.status != RouteActionStatus.Confirmed unloadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
} }
@@ -25,6 +25,7 @@ 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
const val routeStageNotesMaxLength = 2000
private const val googleMapsPackage = "com.google.android.apps.maps" private const val googleMapsPackage = "com.google.android.apps.maps"
data class NavigationIntentCandidate( data class NavigationIntentCandidate(
@@ -39,6 +40,42 @@ data class AppUpdateBannerUi(
val downloaded: Boolean, val downloaded: Boolean,
) )
object RouteLocationMode {
const val Disabled = "disabled"
const val Optional = "optional"
const val Required = "required"
}
fun normalizeRouteLocationMode(value: String?): String = when (value) {
RouteLocationMode.Disabled,
RouteLocationMode.Required,
-> value
else -> RouteLocationMode.Optional
}
fun shouldTrackRouteLocation(
mode: String,
optionalLocationEnabled: Boolean,
hasLocationPermission: Boolean,
): Boolean = hasLocationPermission && when (normalizeRouteLocationMode(mode)) {
RouteLocationMode.Disabled -> false
RouteLocationMode.Required -> true
else -> optionalLocationEnabled
}
fun routeStartRequiresLocationPermission(
mode: String,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required && !hasLocationPermission
fun shouldWarnAboutRequiredRouteLocation(
mode: String,
hasActiveRoute: Boolean,
hasLocationPermission: Boolean,
): Boolean = normalizeRouteLocationMode(mode) == RouteLocationMode.Required &&
hasActiveRoute &&
!hasLocationPermission
fun appUpdateBannerUi( fun appUpdateBannerUi(
currentVersionCode: Int, currentVersionCode: Int,
latestVersionCode: Int?, latestVersionCode: Int?,
@@ -239,6 +276,8 @@ fun routeStageRequirementIsVisible(requirement: String): Boolean =
fun routeStageRequirementIsRequired(requirement: String): Boolean = fun routeStageRequirementIsRequired(requirement: String): Boolean =
normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required normalizeRouteStageRequirement(requirement) == RouteStageRequirement.Required
fun normalizeRouteStageNotes(value: String): String = value.take(routeStageNotesMaxLength)
fun parseRouteWeightTons(weightText: String): Double? { fun parseRouteWeightTons(weightText: String): Double? {
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) { val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
@@ -1,7 +1,10 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import android.Manifest
import android.app.Application import android.app.Application
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
@@ -81,6 +84,8 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true, val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false, val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false, val requirePreciseLocationForPhotos: Boolean = false,
val routeLocationMode: String = RouteLocationMode.Optional,
val routeLocationEnabled: Boolean = false,
val loadingPhotoRequirement: String = RouteStageRequirement.Required, val loadingPhotoRequirement: String = RouteStageRequirement.Required,
val loadingWeightRequirement: String = RouteStageRequirement.Required, val loadingWeightRequirement: String = RouteStageRequirement.Required,
val unloadingPhotoRequirement: String = RouteStageRequirement.Required, val unloadingPhotoRequirement: String = RouteStageRequirement.Required,
@@ -91,6 +96,7 @@ data class DriverUiState(
val selectedRoute: DriverRouteDto? = null, val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val routeStageWeightText: String = "", val routeStageWeightText: String = "",
val routeStageNotesText: String = "",
val routeActions: List<RouteActionEntity> = emptyList(), val routeActions: List<RouteActionEntity> = emptyList(),
val visibleRouteActions: List<RouteActionEntity> = emptyList(), val visibleRouteActions: List<RouteActionEntity> = emptyList(),
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
@@ -240,6 +246,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeProgressNotificationEnabled = enabled) } _state.update { it.copy(routeProgressNotificationEnabled = enabled) }
} }
} }
viewModelScope.launch {
appPreferencesStore.routeLocationEnabled.collect { enabled ->
_state.update { it.copy(routeLocationEnabled = enabled) }
reconcileTrackingServiceFromState()
}
}
viewModelScope.launch { viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads -> photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) } _state.update { it.copy(queuedPhotoUploads = uploads) }
@@ -506,6 +518,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos, ?: it.requirePreciseLocationForPhotos,
routeLocationMode = normalizeRouteLocationMode(settings?.routeLocationMode),
loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement), loadingPhotoRequirement = normalizeRouteStageRequirement(settings?.loadingPhotoRequirement),
loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement), loadingWeightRequirement = normalizeRouteStageRequirement(settings?.loadingWeightRequirement),
unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement), unloadingPhotoRequirement = normalizeRouteStageRequirement(settings?.unloadingPhotoRequirement),
@@ -530,7 +543,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId) if (_state.value.isOnline && !cached.stale) registerPushTokenIfAvailable(driverId)
} }
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate) observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
if (_state.value.isOnline && !cached.stale && settings?.selectedDate == settings?.today) { val trackingSnapshot = _state.value
if (
!shouldTrackRouteLocation(
trackingSnapshot.routeLocationMode,
trackingSnapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.stop(getApplication())
} else if (trackingSnapshot.isOnline && !cached.stale && settings?.selectedDate == settings?.today) {
reconcileTrackingService(response.routes.today, response.session.driver) reconcileTrackingService(response.routes.today, response.session.driver)
} }
}.onFailure { throwable -> }.onFailure { throwable ->
@@ -575,6 +597,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty() DriverScreen.FinishRoute -> response.route.unloadingWeight?.toString().orEmpty()
else -> it.routeStageWeightText else -> it.routeStageWeightText
}, },
routeStageNotesText = when (targetScreen) {
DriverScreen.LoadingWeight -> response.route.loadingNotes.orEmpty()
DriverScreen.FinishRoute -> response.route.unloadingNotes.orEmpty()
else -> it.routeStageNotesText
},
feedback = null, feedback = null,
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis, lastServerSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastServerSyncAtEpochMillis,
@@ -694,6 +721,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"), DiagnosticEntry("Galeria", if (runtime.allowGalleryUploads) "włączona" else "wyłączona"),
DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"), DiagnosticEntry("Kończenie kursu", if (runtime.allowRouteCompletion) "włączone" else "wyłączone"),
DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"), DiagnosticEntry("Dokładna lokalizacja zdjęć", if (runtime.requirePreciseLocationForPhotos) "wymagana" else "niewymagana"),
DiagnosticEntry("Lokalizacja kursu", runtime.routeLocationMode),
DiagnosticEntry("Wybór kierowcy", if (runtime.routeLocationEnabled) "włączony" else "wyłączony"),
DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement), DiagnosticEntry("Załadunek, zdjęcie", runtime.loadingPhotoRequirement),
DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement), DiagnosticEntry("Załadunek, tonaż", runtime.loadingWeightRequirement),
DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement), DiagnosticEntry("Rozładunek, zdjęcie", runtime.unloadingPhotoRequirement),
@@ -790,6 +819,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun setRouteLocationEnabled(enabled: Boolean) {
if (_state.value.routeLocationMode != RouteLocationMode.Optional) return
_state.update { it.copy(routeLocationEnabled = enabled, error = null) }
reconcileTrackingServiceFromState()
viewModelScope.launch {
runCatching { appPreferencesStore.setRouteLocationEnabled(enabled) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun onLocationPermissionGranted() {
val snapshot = _state.value
val driver = snapshot.driver ?: return
if (!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())) {
return
}
val activeRoute = snapshot.displaySelectedRoute
?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" }
?: snapshot.displayRoutes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
?: return
ActiveRouteTrackingService.start(
context = getApplication(),
route = activeRoute,
driver = driver,
progressEnabled = snapshot.routeProgressNotificationEnabled,
)
}
fun openPhotoQueue() { fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
} }
@@ -1101,6 +1160,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openStartRoute() { fun openStartRoute() {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return
if (routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) { if (!canStartRouteFromDriverApp(route, snapshot.selectedDate)) {
_state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") } _state.update { it.copy(error = "Załadunek nie jest dostępny poza zaplanowanym zakresem kursu.") }
return return
@@ -1113,6 +1176,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy( it.copy(
screen = targetScreen, screen = targetScreen,
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = route.loadingNotes.orEmpty(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -1157,6 +1221,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy( it.copy(
screen = DriverScreen.FinishRoute, screen = DriverScreen.FinishRoute,
routeStageWeightText = route.unloadingWeight?.toString().orEmpty(), routeStageWeightText = route.unloadingWeight?.toString().orEmpty(),
routeStageNotesText = route.unloadingNotes.orEmpty(),
feedback = null, feedback = null,
error = null, error = null,
) )
@@ -1168,6 +1233,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeStageWeightText = normalized) } _state.update { it.copy(routeStageWeightText = normalized) }
} }
fun updateRouteStageNotes(value: String) {
_state.update { it.copy(routeStageNotesText = normalizeRouteStageNotes(value)) }
}
fun correctFailedRouteAction() { fun correctFailedRouteAction() {
val snapshot = _state.value val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions) val action = (snapshot.visibleRouteActions + snapshot.routeActions)
@@ -1199,6 +1268,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun retryConflictedRouteAction() {
val snapshot = _state.value
val action = (snapshot.visibleRouteActions + snapshot.routeActions)
.distinctBy { it.clientActionId }
.filter { it.status == RouteActionStatus.FailedConflict }
.maxByOrNull { it.createdAtEpochMillis }
?: return
viewModelScope.launch {
runCatching {
routeActionOutbox.retry(action.clientActionId)
}
.onSuccess {
_state.update {
it.copy(
feedback = "Ponawiam wysyłanie zapisanych danych.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
}
}
fun submitStartRoute() { fun submitStartRoute() {
submitRouteStage(stage = "loading") submitRouteStage(stage = "loading")
} }
@@ -1210,6 +1303,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun submitRouteStage(stage: String) { private fun submitRouteStage(stage: String) {
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return
if (stage == "loading" && routeStartRequiresLocationPermission(snapshot.routeLocationMode, hasLocationPermission())) {
_state.update { it.copy(error = "Lokalizacja jest wymagana, aby rozpocząć kurs.") }
return
}
val canSubmitStage = if (stage == "loading") { val canSubmitStage = if (stage == "loading") {
canStartRouteFromDriverApp(route, snapshot.selectedDate) canStartRouteFromDriverApp(route, snapshot.selectedDate)
} else { } else {
@@ -1233,6 +1330,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} else { } else {
null null
} }
val notes = normalizeRouteStageNotes(snapshot.routeStageNotesText).trim().takeIf { it.isNotBlank() }
val stagePhotos = routePhotosForStage(route.photos, stage) val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = routeStageUsableUploads( val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage }, snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
@@ -1279,21 +1377,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { runCatching {
val updatedRoute = when (stage) { val updatedRoute = when (stage) {
"loading" -> { "loading" -> {
routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds) routeActionOutbox.enqueueStart(route.id, weight, photoClientRequestIds, notes)
route.copy( route.copy(
status = "W TRAKCIE", status = "W TRAKCIE",
driverStatus = "W TRAKCIE", driverStatus = "W TRAKCIE",
loadingWeight = weight, loadingWeight = weight,
loadingNotes = notes,
trackingStatus = "active", trackingStatus = "active",
photos = route.safePhotos(), photos = route.safePhotos(),
) )
} }
"unloading" -> { "unloading" -> {
routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds) routeActionOutbox.enqueueFinish(route.id, weight, photoClientRequestIds, notes)
route.copy( route.copy(
status = "ZAKOŃCZONA", status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA", driverStatus = "ZAKOŃCZONA",
unloadingWeight = weight, unloadingWeight = weight,
unloadingNotes = notes,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = Instant.now().toString(), completedAt = Instant.now().toString(),
photos = route.safePhotos(), photos = route.safePhotos(),
@@ -1308,6 +1408,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
selectedRoute = updatedRoute, selectedRoute = updatedRoute,
routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item }, routes = it.routes.map { item -> if (item.id == route.id) updatedRoute else item },
routeStageWeightText = "", routeStageWeightText = "",
routeStageNotesText = "",
feedback = if (stage == "loading") { feedback = if (stage == "loading") {
if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu." if (snapshot.isOnline) "Potwierdzam załadunek z serwerem." else "Załadunek zapisano w telefonie. Wyślemy go po odzyskaniu internetu."
} else { } else {
@@ -1317,7 +1418,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
if (stage == "loading" && driver != null) { if (
stage == "loading" &&
driver != null &&
shouldTrackRouteLocation(
snapshot.routeLocationMode,
snapshot.routeLocationEnabled,
hasLocationPermission(),
)
) {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
route = updatedRoute, route = updatedRoute,
@@ -1486,9 +1595,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) { when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.StartRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) DriverScreen.StartRoute -> it.copy(
screen = DriverScreen.Detail,
routeStageWeightText = "",
routeStageNotesText = "",
feedback = null,
)
DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null) DriverScreen.LoadingWeight -> it.copy(screen = DriverScreen.StartRoute, feedback = null)
DriverScreen.FinishRoute -> it.copy(screen = DriverScreen.Detail, routeStageWeightText = "", feedback = null) DriverScreen.FinishRoute -> it.copy(
screen = DriverScreen.Detail,
routeStageWeightText = "",
routeStageNotesText = "",
feedback = null,
)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes) DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
@@ -1571,8 +1690,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) { private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val snapshot = _state.value
val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" } val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (activeRoute == null) { if (
activeRoute == null ||
!shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
ActiveRouteTrackingService.stop(getApplication()) ActiveRouteTrackingService.stop(getApplication())
} else { } else {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
@@ -1585,7 +1708,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
if (route.driverLifecycleStatus() == "W TRAKCIE") { val snapshot = _state.value
if (
route.driverLifecycleStatus() == "W TRAKCIE" &&
shouldTrackRouteLocation(snapshot.routeLocationMode, snapshot.routeLocationEnabled, hasLocationPermission())
) {
if (driver != null) { if (driver != null) {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
@@ -1599,6 +1726,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
private fun reconcileTrackingServiceFromState() {
val snapshot = _state.value
val driver = snapshot.driver
if (driver == null) {
ActiveRouteTrackingService.stop(getApplication())
return
}
reconcileTrackingService(snapshot.displayRoutes, driver)
}
private fun hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(getApplication(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun observeRouteActions(routeId: String) { private fun observeRouteActions(routeId: String) {
routeActionsJob?.cancel() routeActionsJob?.cancel()
routeActionsJob = viewModelScope.launch { routeActionsJob = viewModelScope.launch {
@@ -0,0 +1,337 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Mic
import androidx.compose.material.icons.outlined.StopCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import pl.firmatpp.kierowca.ui.theme.TppTheme
internal data class VoiceDictationUiState(
val isListening: Boolean = false,
val message: String = "Możesz wpisać uwagę albo podyktować ją po polsku.",
val isError: Boolean = false,
)
internal fun mergeDictationText(existingText: String, recognizedText: String): String {
val existing = existingText.trimEnd()
val recognized = recognizedText.trim()
if (recognized.isBlank()) return normalizeRouteStageNotes(existingText)
return normalizeRouteStageNotes(
if (existing.isBlank()) recognized else "$existing $recognized",
)
}
@Composable
internal fun VoiceNotesField(
value: String,
onValueChange: (String) -> Unit,
stageLabel: String,
enabled: Boolean,
onListeningChanged: (Boolean) -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var dictationState by remember { mutableStateOf(VoiceDictationUiState()) }
val currentValue by rememberUpdatedState(value)
val currentOnValueChange by rememberUpdatedState(onValueChange)
val controller = remember(context) { AndroidLiveVoiceDictation(context.applicationContext) }
val startDictation = {
controller.start(
existingText = currentValue,
onText = currentOnValueChange,
onState = { dictationState = it },
)
}
val microphonePermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
startDictation()
} else {
dictationState = VoiceDictationUiState(
message = "Brak dostępu do mikrofonu. Możesz nadal wpisać uwagę ręcznie.",
isError = true,
)
}
}
LaunchedEffect(dictationState.isListening) {
onListeningChanged(dictationState.isListening)
}
DisposableEffect(controller) {
onDispose {
onListeningChanged(false)
controller.destroy()
}
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = value,
onValueChange = { currentOnValueChange(normalizeRouteStageNotes(it)) },
label = { Text("Uwagi do $stageLabel (opcjonalnie)") },
placeholder = { Text("Np. oczekiwanie, uszkodzenie, problem z dokumentami…") },
supportingText = {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Zapisujemy tylko tekst uwagi.")
Text("${value.length}/$routeStageNotesMaxLength")
}
},
readOnly = dictationState.isListening,
enabled = enabled,
minLines = 3,
maxLines = 6,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = {
if (dictationState.isListening) {
controller.stop()
} else if (
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
) {
startDictation()
} else {
microphonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
enabled = enabled,
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp),
) {
Icon(
imageVector = if (dictationState.isListening) Icons.Outlined.StopCircle else Icons.Outlined.Mic,
contentDescription = null,
)
Text(
text = if (dictationState.isListening) "Zakończ dyktowanie" else "Dyktuj uwagę",
modifier = Modifier.padding(start = 10.dp),
fontWeight = FontWeight.Bold,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (dictationState.isListening) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = TppTheme.colors.forest,
)
}
Text(
text = dictationState.message,
color = if (dictationState.isError) Color(0xFFB42318) else TppTheme.colors.muted,
)
}
}
}
private class AndroidLiveVoiceDictation(
private val context: Context,
) : RecognitionListener {
private var recognizer: SpeechRecognizer? = null
private var prefix: String = ""
private var onText: ((String) -> Unit)? = null
private var onState: ((VoiceDictationUiState) -> Unit)? = null
private var listening = false
fun start(
existingText: String,
onText: (String) -> Unit,
onState: (VoiceDictationUiState) -> Unit,
) {
if (! SpeechRecognizer.isRecognitionAvailable(context)) {
onState(
VoiceDictationUiState(
message = "Na tym telefonie nie ma usługi rozpoznawania mowy. Wpisz uwagę ręcznie.",
isError = true,
),
)
return
}
this.prefix = existingText
this.onText = onText
this.onState = onState
if (recognizer == null) {
recognizer = SpeechRecognizer.createSpeechRecognizer(context).also {
it.setRecognitionListener(this)
}
}
listening = true
emitState("Uruchamiam mikrofon…")
runCatching {
recognizer?.startListening(recognitionIntent())
}.onFailure {
listening = false
emitState("Nie udało się uruchomić dyktowania. Spróbuj ponownie.", isError = true)
}
}
fun stop() {
if (! listening) return
emitState("Kończę i sprawdzam rozpoznany tekst…")
recognizer?.stopListening()
}
fun destroy() {
listening = false
onText = null
onState = null
recognizer?.cancel()
recognizer?.destroy()
recognizer = null
}
override fun onReadyForSpeech(params: Bundle?) {
emitState("Słucham… Mów normalnie. Rozpoznany tekst pojawia się wyżej na żywo.")
}
override fun onBeginningOfSpeech() {
emitState("Rozpoznaję mowę na żywo…")
}
override fun onRmsChanged(rmsdB: Float) = Unit
override fun onBufferReceived(buffer: ByteArray?) = Unit
override fun onEndOfSpeech() {
emitState("Sprawdzam rozpoznany tekst…")
}
override fun onError(error: Int) {
listening = false
emitState(speechRecognitionErrorMessage(error), isError = true)
}
override fun onResults(results: Bundle?) {
val recognized = bestRecognitionResult(results)
if (recognized.isNotBlank()) {
onText?.invoke(mergeDictationText(prefix, recognized))
}
listening = false
emitState(
if (recognized.isBlank()) {
"Nie wykryto tekstu. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
} else {
"Rozpoznano: „${recognized.take(160)}”. Możesz teraz poprawić tekst."
},
isError = recognized.isBlank(),
)
}
override fun onPartialResults(partialResults: Bundle?) {
val recognized = bestRecognitionResult(partialResults)
if (recognized.isBlank()) return
onText?.invoke(mergeDictationText(prefix, recognized))
emitState("Na żywo: „${recognized.take(160)}")
}
override fun onEvent(eventType: Int, params: Bundle?) = Unit
private fun recognitionIntent(): Intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "pl-PL")
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, false)
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
putExtra(
RecognizerIntent.EXTRA_ENABLE_FORMATTING,
RecognizerIntent.FORMATTING_OPTIMIZE_LATENCY,
)
putExtra(RecognizerIntent.EXTRA_HIDE_PARTIAL_TRAILING_PUNCTUATION, true)
}
}
private fun bestRecognitionResult(bundle: Bundle?): String =
bundle
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
?.firstOrNull()
.orEmpty()
.trim()
private fun emitState(message: String, isError: Boolean = false) {
onState?.invoke(
VoiceDictationUiState(
isListening = listening,
message = message,
isError = isError,
),
)
}
}
internal fun speechRecognitionErrorMessage(error: Int): String = when (error) {
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS ->
"Brak dostępu do mikrofonu. Możesz wpisać uwagę ręcznie."
SpeechRecognizer.ERROR_SPEECH_TIMEOUT ->
"Nie usłyszałem mowy. Naciśnij „Dyktuj uwagę” i spróbuj ponownie."
SpeechRecognizer.ERROR_NO_MATCH ->
"Nie rozpoznano słów. Mów wyraźnie i spróbuj ponownie."
SpeechRecognizer.ERROR_NETWORK,
SpeechRecognizer.ERROR_NETWORK_TIMEOUT ->
"Rozpoznawanie mowy nie ma teraz połączenia. Spróbuj ponownie lub wpisz uwagę."
SpeechRecognizer.ERROR_RECOGNIZER_BUSY ->
"Mikrofon jest teraz zajęty. Odczekaj chwilę i spróbuj ponownie."
SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED,
SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE ->
"Polskie rozpoznawanie mowy nie jest dostępne na tym telefonie."
else ->
"Nie udało się rozpoznać mowy. Spróbuj ponownie lub wpisz uwagę ręcznie."
}
@@ -0,0 +1,94 @@
package pl.firmatpp.kierowca.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.CancellationSignal
import android.os.Looper
import androidx.core.content.ContextCompat
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
private const val PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS = 5_000L
internal suspend fun currentPhotoLocation(context: Context): Location? {
if (!hasAnyLocationPermission(context)) return null
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val hasFineLocation = ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val providers = buildList {
if (hasFineLocation) add(LocationManager.GPS_PROVIDER)
add(LocationManager.NETWORK_PROVIDER)
}.filter { provider -> runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false) }
for (provider in providers) {
val location = withTimeoutOrNull(PHOTO_LOCATION_PROVIDER_TIMEOUT_MILLIS) {
manager.awaitSingleLocation(context, provider)
}
if (location != null) return location
}
return bestLastKnownLocation(manager)
}
private suspend fun LocationManager.awaitSingleLocation(
context: Context,
provider: String,
): Location? = suspendCancellableCoroutine { continuation ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancellationSignal = CancellationSignal()
continuation.invokeOnCancellation { cancellationSignal.cancel() }
runCatching {
getCurrentLocation(provider, cancellationSignal, context.mainExecutor) { location ->
if (continuation.isActive) continuation.resume(location)
}
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
return@suspendCancellableCoroutine
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
removeUpdates(this)
if (continuation.isActive) continuation.resume(location)
}
}
continuation.invokeOnCancellation { runCatching { removeUpdates(listener) } }
runCatching {
@Suppress("DEPRECATION")
requestSingleUpdate(provider, listener, Looper.getMainLooper())
}.onFailure {
if (continuation.isActive) continuation.resume(null)
}
}
private fun hasAnyLocationPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun bestLastKnownLocation(manager: LocationManager): Location? {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
return providers.mapNotNull { provider ->
runCatching {
if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) {
manager.getLastKnownLocation(provider)
} else {
null
}
}.getOrNull()
}.maxByOrNull { it.time }
}
@@ -0,0 +1,19 @@
package pl.firmatpp.kierowca.data.api
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Test
class ApiFactoryTest {
@Test
fun `request contains current driver app version header`() {
val request = Request.Builder()
.url("https://api.example.test/mobile/driver/bootstrap")
.header(DRIVER_APP_VERSION_HEADER, "old-version")
.build()
val updated = request.withDriverAppVersion("1.0.65")
assertEquals("1.0.65", updated.header(DRIVER_APP_VERSION_HEADER))
}
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertEquals
import org.junit.Test
class RouteActionPrerequisiteTest {
@Test
fun finishWaitsUntilEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val pendingStart = action(RouteActionType.Start, RouteActionStatus.Syncing, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Waiting,
routeActionPrerequisiteState(finish, pendingStart),
)
}
@Test
fun finishRunsAfterEarlierStartIsConfirmed() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val confirmedStart = action(RouteActionType.Start, RouteActionStatus.Confirmed, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, confirmedStart),
)
}
@Test
fun failedStartBlocksFinishInsteadOfSendingItOutOfOrder() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
val failedStart = action(RouteActionType.Start, RouteActionStatus.FailedConflict, createdAt = 1_000)
assertEquals(
RouteActionPrerequisiteState.Failed,
routeActionPrerequisiteState(finish, failedStart),
)
}
@Test
fun finishWithoutLocalStartCanUseServerLifecycleState() {
val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000)
assertEquals(
RouteActionPrerequisiteState.Ready,
routeActionPrerequisiteState(finish, null),
)
}
private fun action(type: String, status: String, createdAt: Long): RouteActionEntity =
RouteActionEntity(
clientActionId = "$type-$createdAt",
routeId = "route-1",
action = type,
weight = 12.5,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = "[]",
status = status,
createdAtEpochMillis = createdAt,
)
}
@@ -0,0 +1,135 @@
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.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class RouteActionServerReconciliationTest {
@Test
fun matchingFinishedRouteConfirmsDuplicateFinishSafely() {
assertTrue(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uszkodzona plomba."),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Uszkodzona plomba.",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun differingNotesKeepConflictVisibleAndPreserveLocalData() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = 11.8, notes = "Uwagi z telefonu"),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(
status = "ZAKOŃCZONA",
weight = 11.8,
notes = "Inna uwaga na serwerze",
photoClientRequestIds = listOf("photo-1"),
),
),
)
}
@Test
fun missingServerPhotoDoesNotDiscardLocalFinish() {
assertFalse(
routeActionMatchesServer(
action = finish(weight = null, notes = null),
photoClientRequestIds = listOf("photo-1"),
serverRoute = route(status = "ZAKOŃCZONA", weight = null, notes = null),
),
)
}
@Test
fun laterConfirmedFinishSupersedesAnOlderConflictingFinish() {
assertTrue(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Starsza uwaga"),
hasConfirmedFinishAfter = true,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
@Test
fun completedServerRouteAloneDoesNotDiscardConflictingLocalData() {
assertFalse(
routeActionWasSuperseded(
action = finish(weight = 11.8, notes = "Nadal ważna uwaga"),
hasConfirmedFinishAfter = false,
serverRoute = route(
status = "ZAKOŃCZONA",
weight = null,
notes = null,
),
),
)
}
private fun finish(weight: Double?, notes: String?): RouteActionEntity =
RouteActionEntity(
clientActionId = "finish-1",
routeId = "route-1",
action = RouteActionType.Finish,
weight = weight,
occurredAt = "2026-07-23T10:00:00Z",
photoClientRequestIdsJson = """["photo-1"]""",
notes = notes,
)
private fun route(
status: String,
weight: Double?,
notes: String?,
photoClientRequestIds: List<String> = emptyList(),
): DriverRouteDto =
DriverRouteDto(
id = "route-1",
routeDate = "2026-07-23",
startsAt = "",
originName = "Baza",
destinationName = "Cel",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza → Cel",
status = status,
driverStatus = status,
unloadingWeight = weight,
unloadingNotes = notes,
distanceKm = 1.0,
notes = null,
truck = null,
photos = photoClientRequestIds.mapIndexed { index, requestId ->
RoutePhotoDto(
id = "photo-$index",
routeId = "route-1",
clientRequestId = requestId,
source = "camera",
stage = "unloading",
mimeType = "image/jpeg",
size = 1,
url = "https://example.test/photo-$index",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
},
)
}
@@ -17,11 +17,19 @@ class DriverRouteProjectionTest {
fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() { fun pendingStartProjectsPlannedRouteAsActiveWithLoadingWeight() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"), route = route(status = "ZAPLANOWANA"),
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Pending, weight = 12.5)), actions = listOf(
action(
RouteActionType.Start,
RouteActionStatus.Pending,
weight = 12.5,
notes = "Oczekiwanie na dokument WZ.",
),
),
) )
assertEquals("W TRAKCIE", projection.route.status) assertEquals("W TRAKCIE", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertEquals("Oczekiwanie na dokument WZ.", projection.route.loadingNotes)
assertEquals("active", projection.route.trackingStatus) assertEquals("active", projection.route.trackingStatus)
assertTrue(projection.loadingWeightPending) assertTrue(projection.loadingWeightPending)
assertFalse(projection.unloadingWeightPending) assertFalse(projection.unloadingWeightPending)
@@ -32,11 +40,20 @@ class DriverRouteProjectionTest {
fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() { fun pendingFinishProjectsActiveRouteAsFinishedWithUnloadingWeight() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5), route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.Syncing, weight = 11.8, occurredAt = "2026-06-30T09:10:00Z")), actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.Syncing,
weight = 11.8,
notes = "Uszkodzona plomba.",
occurredAt = "2026-06-30T09:10:00Z",
),
),
) )
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight) assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertEquals("finished", projection.route.trackingStatus) assertEquals("finished", projection.route.trackingStatus)
assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt) assertEquals("2026-06-30T09:10:00Z", projection.route.completedAt)
assertTrue(projection.unloadingWeightPending) assertTrue(projection.unloadingWeightPending)
@@ -108,16 +125,25 @@ class DriverRouteProjectionTest {
} }
@Test @Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { fun conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() {
val projection = projectDriverRoute( val projection = projectDriverRoute(
route = route(status = "W TRAKCIE", loadingWeight = 12.5), route = route(status = "W TRAKCIE", loadingWeight = 12.5),
actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)), actions = listOf(
action(
RouteActionType.Finish,
RouteActionStatus.FailedConflict,
weight = 11.8,
notes = "Uszkodzona plomba.",
),
),
) )
val steps = routeFlowSteps(projection.route, projection.visibleActions) val steps = routeFlowSteps(projection.route, projection.visibleActions)
val callout = routeSyncCallout(projection.visibleActions) val callout = routeSyncCallout(projection.visibleActions)
assertEquals("ZAKOŃCZONA", projection.route.status) assertEquals("W TRAKCIE", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight) assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes)
assertTrue(projection.unloadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false) assertTrue(callout?.isConflict ?: false)
@@ -131,8 +157,9 @@ class DriverRouteProjectionTest {
) )
val steps = routeFlowSteps(projection.route, projection.visibleActions) val steps = routeFlowSteps(projection.route, projection.visibleActions)
assertEquals("W TRAKCIE", projection.route.status) assertEquals("ZAPLANOWANA", projection.route.status)
assertEquals(12.5, projection.route.loadingWeight) assertEquals(12.5, projection.route.loadingWeight)
assertTrue(projection.loadingWeightPending)
assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state) assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state)
} }
@@ -174,6 +201,7 @@ class DriverRouteProjectionTest {
type: String, type: String,
status: String, status: String,
weight: Double, weight: Double,
notes: String? = null,
occurredAt: String = "2026-06-30T07:40:00Z", occurredAt: String = "2026-06-30T07:40:00Z",
createdAt: Long = 1000, createdAt: Long = 1000,
): RouteActionEntity = ): RouteActionEntity =
@@ -186,6 +214,7 @@ class DriverRouteProjectionTest {
photoClientRequestIdsJson = "[]", photoClientRequestIdsJson = "[]",
status = status, status = status,
createdAtEpochMillis = createdAt, createdAtEpochMillis = createdAt,
notes = notes,
) )
private companion object { private companion object {
@@ -16,6 +16,29 @@ import pl.firmatpp.kierowca.data.upload.RouteActionType
class DriverUiRulesTest { class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30") private val today = LocalDate.parse("2026-06-30")
@Test
fun disabledRouteLocationNeverStartsTracking() {
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Disabled, optionalLocationEnabled = false, hasLocationPermission = true))
}
@Test
fun optionalRouteLocationFollowsDriverChoiceAndPermission() {
assertTrue(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = false, hasLocationPermission = true))
assertFalse(shouldTrackRouteLocation(RouteLocationMode.Optional, optionalLocationEnabled = true, hasLocationPermission = false))
}
@Test
fun requiredRouteLocationBlocksStartAndWarnsOnlyWhenNeeded() {
assertTrue(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = false))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Required, hasLocationPermission = true))
assertFalse(routeStartRequiresLocationPermission(RouteLocationMode.Optional, hasLocationPermission = false))
assertTrue(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = true, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Required, hasActiveRoute = false, hasLocationPermission = false))
assertFalse(shouldWarnAboutRequiredRouteLocation(RouteLocationMode.Optional, hasActiveRoute = true, hasLocationPermission = false))
}
@Test @Test
fun allowsPhotoActionsOnlyForTodayRouteDate() { fun allowsPhotoActionsOnlyForTodayRouteDate() {
assertTrue(canManageRoutePhotos("2026-06-30", today)) assertTrue(canManageRoutePhotos("2026-06-30", today))
@@ -0,0 +1,30 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class LiveVoiceDictationTest {
@Test
fun recognizedTextIsAppendedToExistingTypedNote() {
assertEquals(
"Oczekiwanie na dokumenty kierowca odebrał WZ",
mergeDictationText("Oczekiwanie na dokumenty ", " kierowca odebrał WZ "),
)
}
@Test
fun recognizedTextCanStartAnEmptyNote() {
assertEquals(
"Uszkodzona plomba",
mergeDictationText("", " Uszkodzona plomba "),
)
}
@Test
fun routeNotesAreLimitedToApiMaximum() {
assertEquals(
routeStageNotesMaxLength,
normalizeRouteStageNotes("a".repeat(routeStageNotesMaxLength + 20)).length,
)
}
}
+16 -2
View File
@@ -138,6 +138,14 @@ if [[ "$sanitized_remote" =~ ^(https?)://([^/]+)/(.+)$ ]]; then
detected_protocol="${BASH_REMATCH[1]}" detected_protocol="${BASH_REMATCH[1]}"
detected_host="${BASH_REMATCH[2]}" detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}" detected_repository="${BASH_REMATCH[3]%.git}"
elif [[ "$remote_url" =~ ^ssh://([^/@]+@)?([^/:]+)(:[0-9]+)?/(.+)$ ]]; then
detected_protocol="https"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[4]%.git}"
elif [[ "$remote_url" =~ ^([^/@:]+@)?([^/:]+):(.+)$ ]]; then
detected_protocol="https"
detected_host="${BASH_REMATCH[2]}"
detected_repository="${BASH_REMATCH[3]%.git}"
else else
detected_protocol="https" detected_protocol="https"
detected_host="" detected_host=""
@@ -147,14 +155,20 @@ fi
gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}" gitea_base_url="${GITEA_BASE_URL:-${detected_protocol}://${detected_host}}"
gitea_repository="${GITEA_REPOSITORY:-$detected_repository}" gitea_repository="${GITEA_REPOSITORY:-$detected_repository}"
gitea_base_url="${gitea_base_url%/}" gitea_base_url="${gitea_base_url%/}"
[[ "$gitea_base_url" =~ ^https?://[^/]+$ ]] || { echo "Ustaw poprawne GITEA_BASE_URL." >&2; exit 1; } if [[ "$gitea_base_url" =~ ^(https?)://([^/]+)$ ]]; then
gitea_api_protocol="${BASH_REMATCH[1]}"
gitea_api_host="${BASH_REMATCH[2]}"
else
echo "Ustaw poprawne GITEA_BASE_URL." >&2
exit 1
fi
[[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; } [[ "$gitea_repository" =~ ^[^/]+/[^/]+$ ]] || { echo "Ustaw poprawne GITEA_REPOSITORY=owner/repository." >&2; exit 1; }
auth_args=() auth_args=()
if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -n "${GITEA_TOKEN:-}" ]]; then
auth_args=(-H "Authorization: token $GITEA_TOKEN") auth_args=(-H "Authorization: token $GITEA_TOKEN")
else else
credential="$(printf 'protocol=%s\nhost=%s\n\n' "$detected_protocol" "$detected_host" | git credential fill)" credential="$(printf 'protocol=%s\nhost=%s\n\n' "$gitea_api_protocol" "$gitea_api_host" | git credential fill)"
gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')" gitea_username="$(printf '%s\n' "$credential" | sed -n 's/^username=//p')"
gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')" gitea_password="$(printf '%s\n' "$credential" | sed -n 's/^password=//p')"
[[ -n "$gitea_username" && -n "$gitea_password" ]] || { [[ -n "$gitea_username" && -n "$gitea_password" ]] || {