Compare commits

..
Author SHA1 Message Date
admin de8510bc79 Wydaj aplikację kierowcy 1.0.65 (118) 2026-07-23 11:33:17 +02:00
admin 34d01b312e Obsłuż adres SSH w skrypcie wydania Androida 2026-07-23 11:19:10 +02:00
admin f89332b28c Dodaj uwagi i dyktowanie na etapach trasy 2026-07-23 11:05:49 +02:00
admin 8f12bf32bc Wydaj aplikację kierowcy 1.0.64 (117) 2026-07-18 17:37:53 +02:00
admin b9ae128842 Napraw obsługę kodów OTP podczas logowania 2026-07-18 17:33:40 +02:00
admin a86dca50b8 Wydaj aplikację kierowcy 1.0.63 (116) 2026-07-18 16:34:32 +02:00
admin fc49eae512 Zatrzymaj aktywny kurs po wygaśnięciu sesji 2026-07-18 16:29:34 +02:00
admin ffbae9edfd Wydaj aplikację kierowcy 1.0.62 (115) 2026-07-18 02:57:39 +02:00
admin 2268c64250 Zautomatyzuj publikację aplikacji w Google Play 2026-07-18 02:57:03 +02:00
admin c954fd9bb8 chore: release android driver 1.0.61 (114) 2026-07-18 02:34:10 +02:00
admin b9757348f2 Dodaj aktualizacje aplikacji kierowcy przez Google Play 2026-07-18 02:32:31 +02:00
admin 130f8bbecc chore: release android driver 1.0.60 (113) 2026-07-17 14:10:03 +02:00
admin 4c00d46a14 Napraw otwieranie nawigacji do geofence 2026-07-17 14:05:11 +02:00
admin 07fc4021e1 chore: release android driver 1.0.59 (112) 2026-07-17 13:33:46 +02:00
admin 431436d0db Napraw synchronizację kursu w aplikacji kierowcy 2026-07-17 13:28:49 +02:00
admin 905711d8de chore: release android driver 1.0.58 (111) 2026-07-16 11:06:24 +02:00
admin 7c9cb3fb0d Dodaj timeout i ręczny tryb offline przy uruchamianiu 2026-07-16 11:04:36 +02:00
admin ae0d1b2d3d chore: release android driver 1.0.57 (110) 2026-07-16 10:31:11 +02:00
admin c5b3c5dae9 Przeliczaj wagi kierowcy z kilogramów na tony 2026-07-16 10:28:30 +02:00
admin 3fa3b4eac1 chore: release android driver 1.0.56 (109) 2026-07-12 22:34:20 +02:00
admin 197bcc6f5e Przypomnij o zgodzie na powiadomienia Android 2026-07-12 22:33:52 +02:00
47 changed files with 2785 additions and 187 deletions
+2
View File
@@ -9,3 +9,5 @@ keystore.properties
*.jks *.jks
release-artifacts/ release-artifacts/
app/src/main/res_bak/ app/src/main/res_bak/
scripts/*.json
scripts/*.json:Zone.Identifier
+45 -3
View File
@@ -31,9 +31,51 @@ Przed wydaniem zapisz wszystkie zmiany źródłowe w commicie. Następnie urucho
``` ```
Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy, Skrypt automatycznie zwiększa `versionCode` i ostatni człon `versionName`, uruchamia testy,
buduje podpisany APK dla Gitea oraz AAB dla Google Play Console, tworzy commit wersji, buduje podpisany APK oraz AAB, a po poprawnym buildzie pyta, czy wysłać aktualizację do
tag, push i Gitea Release. Artefakty zostają również zapisane lokalnie w Google Play. Po odpowiedzi „tak” publikuje AAB w skonfigurowanym teście zamkniętym i
`release-artifacts/<tag>/`. wysyła zmiany do weryfikacji. Następnie tworzy commit wersji, tag, push i Gitea Release.
Artefakty zostają również zapisane lokalnie w `release-artifacts/<tag>/`.
Do publikacji Google Play konto serwisowe musi mieć dla aplikacji
`pl.firmatpp.kierowca` uprawnienia „Wyświetlanie informacji o aplikacji (tylko do odczytu)”
oraz „Publikowanie aplikacji na ścieżkach testowych”. Pobrany klucz JSON umieść
bezpośrednio w folderze `scripts`, na przykład:
```text
scripts/google-play-service-account.json
```
Skrypt sam znajduje jedyny plik `scripts/*.json`, sprawdza, czy jest prawidłowym kluczem
konta serwisowego, i używa go do autoryzacji. Pliki `scripts/*.json` są ignorowane przez
Git i nie mogą zostać przypadkowo dodane do repozytorium. Jeśli w folderze znajduje się
więcej niż jeden prawidłowy klucz, skrypt przerwie działanie i poprosi o pozostawienie
jednego.
`GOOGLE_PLAY_TRACK` musi być identyfikatorem istniejącego testu zamkniętego. Domyślna
wartość to `alpha`. Opcjonalnie można ustawić `GOOGLE_PLAY_PACKAGE_NAME` i
`GOOGLE_PLAY_RELEASE_LANGUAGE` (domyślnie `pl-PL`). Zmienna
`GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS` pozostaje opcjonalnym sposobem wskazania klucza
spoza folderu `scripts`. Skrypt nie zapisuje tokenu ani klucza w repozytorium; prywatny
klucz tymczasowy jest usuwany po zakończeniu.
Przed zatwierdzeniem skrypt waliduje edycję Google Play. Commit edycji jawnie wysyła
zmiany do weryfikacji. Jeżeli inne zmiany są już sprawdzane, skrypt nie anuluje ich —
kończy się błędem i można go wznowić po zakończeniu poprzedniej weryfikacji.
Jeżeli na koncie jest włączone publikowanie zarządzane, po akceptacji Google nadal trzeba
wybrać „Opublikuj zmiany” w Play Console; bez publikowania zarządzanego zaakceptowane
wydanie zostanie udostępnione testerom automatycznie.
Awaryjne wydanie wyłącznie do Gitea, bez Google Play:
```bash
./scripts/release-android.sh --skip-google-play
```
W środowisku bez interaktywnego terminala decyzję trzeba podać jawnie:
```bash
./scripts/release-android.sh --publish-google-play
```
Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper. Do autoryzacji Gitea używany jest `GITEA_TOKEN` albo dane zapisane w Git credential helper.
Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić: Jeśli publikacja została przerwana już po utworzeniu commita wersji, można ją wznowić:
+4 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 108 versionCode = 118
versionName = "1.0.55" versionName = "1.0.65"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -130,6 +130,8 @@ dependencies {
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.play.services.auth) implementation(libs.play.services.auth)
implementation(libs.play.services.auth.api.phone) implementation(libs.play.services.auth.api.phone)
implementation(libs.play.app.update)
implementation(libs.play.app.update.ktx)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.gson) implementation(libs.retrofit.gson)
+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"
@@ -16,7 +16,10 @@ class DriverApplication : Application() {
AppDiagnostics.installFirebaseCrashlytics() AppDiagnostics.installFirebaseCrashlytics()
AppDiagnostics.log("app_started") AppDiagnostics.log("app_started")
applicationScope.launch { applicationScope.launch {
runCatching { OfflineOutboxManager(this@DriverApplication).repairGpsQueue() } val outboxManager = OfflineOutboxManager(this@DriverApplication)
runCatching { outboxManager.repairOperationalQueues() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_operational_queues") }
runCatching { outboxManager.repairGpsQueue() }
.onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") } .onFailure { AppDiagnostics.reportNonFatal(it, "repair_gps_queue") }
} }
} }
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -11,17 +12,33 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import pl.firmatpp.kierowca.ui.DriverApp import pl.firmatpp.kierowca.ui.DriverApp
import pl.firmatpp.kierowca.ui.DriverViewModel import pl.firmatpp.kierowca.ui.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
import pl.firmatpp.kierowca.update.PlayInAppUpdateController
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null) private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationRouteTarget by mutableStateOf<String?>(null) private var notificationRouteTarget by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null) private var notificationLeaveRequestId by mutableStateOf<String?>(null)
private var playAppUpdateState by mutableStateOf(PlayAppUpdateState())
private lateinit var playInAppUpdates: PlayInAppUpdateController
private val appUpdateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.onUpdateFlowResult(result.resultCode)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET) notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
playInAppUpdates = PlayInAppUpdateController(
activity = this,
launcher = appUpdateLauncher,
onStateChanged = { playAppUpdateState = it },
)
setContent { setContent {
val viewModel: DriverViewModel = viewModel() val viewModel: DriverViewModel = viewModel()
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -32,9 +49,27 @@ class MainActivity : ComponentActivity() {
initialRouteId = notificationRouteId, initialRouteId = notificationRouteId,
initialRouteTarget = notificationRouteTarget, initialRouteTarget = notificationRouteTarget,
initialLeaveRequestId = notificationLeaveRequestId, initialLeaveRequestId = notificationLeaveRequestId,
playAppUpdateState = playAppUpdateState,
onStartAppUpdate = playInAppUpdates::startUpdate,
onCompleteAppUpdate = playInAppUpdates::completeDownloadedUpdate,
) )
} }
} }
playInAppUpdates.checkForUpdate()
}
override fun onResume() {
super.onResume()
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.checkForUpdate()
}
}
override fun onDestroy() {
if (::playInAppUpdates.isInitialized) {
playInAppUpdates.dispose()
}
super.onDestroy()
} }
override fun onNewIntent(intent: android.content.Intent) { override fun onNewIntent(intent: android.content.Intent) {
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import retrofit2.HttpException import retrofit2.HttpException
enum class ApiErrorKind { enum class ApiErrorKind {
@@ -49,7 +50,10 @@ object ApiErrorMapper {
map(throwable).kind != ApiErrorKind.Network map(throwable).kind != ApiErrorKind.Network
fun mapHttpStatus(statusCode: Int, body: String?): ApiError { fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
val code = body?.let { """"code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } val code = body?.let {
""""code"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
?: """"reason"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1)
}
val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) } val message = body?.let { """"message"\s*:\s*"([^"]+)"""".toRegex().find(it)?.groupValues?.getOrNull(1) }
val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() } val retryable = body?.let { """"retryable"\s*:\s*(true|false)""".toRegex().find(it)?.groupValues?.getOrNull(1)?.toBooleanStrictOrNull() }
@@ -80,16 +84,21 @@ object ApiErrorMapper {
} }
val defaultRetryable = statusCode == 429 || statusCode in 500..599 val defaultRetryable = statusCode == 429 || statusCode in 500..599
val resolvedRetryable = retryable ?: defaultRetryable val resolvedRetryable = retryable ?: defaultRetryable
val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) { val resolvedMessage = when {
code == "INVALID_OTP" -> "Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a."
kind == ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
!message.isNullOrBlank() -> message
else -> when (kind) {
ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie." ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie."
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji." ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana." ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych." ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych."
ApiErrorKind.RateLimited -> "Za dużo prób. Aplikacja spróbuje ponownie później." ApiErrorKind.RateLimited -> "Za dużo prób. Odczekaj chwilę i spróbuj ponownie."
ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie." ApiErrorKind.Server -> "Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie."
ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona." ApiErrorKind.Network -> "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona."
ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona." ApiErrorKind.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona."
} }
}
return ApiError( return ApiError(
kind = kind, kind = kind,
@@ -102,7 +111,15 @@ object ApiErrorMapper {
} }
private fun mapHttpException(exception: HttpException): ApiError { private fun mapHttpException(exception: HttpException): ApiError {
val body = runCatching { exception.response()?.errorBody()?.string() }.getOrNull() val body = runCatching {
val source = exception.response()?.errorBody()?.source() ?: return@runCatching null
source.request(Long.MAX_VALUE)
source.buffer.clone().readUtf8()
}.getOrNull()
return mapHttpStatus(exception.code(), body) return mapHttpStatus(exception.code(), body)
} }
} }
fun Throwable.rethrowIfCancellation() {
if (this is CancellationException) throw this
}
@@ -50,6 +50,16 @@ data class BootstrapResponse(
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null, val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
val appUpdate: AppUpdateDto? = null,
)
data class AppUpdateDto(
val latestVersionCode: Int? = null,
val latestVersionName: String? = null,
val minimumSupportedVersionCode: Int = 0,
val playStoreUrl: String? = null,
val track: String? = null,
val checkedAt: String? = null,
) )
data class DriverAppSettingsDto( data class DriverAppSettingsDto(
@@ -265,14 +275,19 @@ data class DriverRouteDto(
val contractCode: String?, val contractCode: String?,
val relationLabel: String, val relationLabel: String,
val status: String, val status: String,
val driverStatus: String? = null,
val operationalStatus: String? = null,
val loadingWeight: Double? = null, val loadingWeight: Double? = null,
val 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?,
val notes: String?, val notes: String?,
val truck: TruckDto?, val truck: TruckDto?,
val completedAt: String? = null, val completedAt: String? = null,
val operationalCompletedAt: String? = null,
val completedByDriver: DriverDto? = null, val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null, val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null, val destinationNavigation: NavigationPointDto? = null,
@@ -280,6 +295,9 @@ data class DriverRouteDto(
val photosByStage: RoutePhotosByStageDto? = null, val photosByStage: RoutePhotosByStageDto? = null,
) )
fun DriverRouteDto.driverLifecycleStatus(): String =
driverStatus?.takeIf { it in setOf("ZAPLANOWANA", "W TRAKCIE", "ZAKOŃCZONA") } ?: status
data class RoutePhotosByStageDto( data class RoutePhotosByStageDto(
val loading: List<RoutePhotoDto> = emptyList(), val loading: List<RoutePhotoDto> = emptyList(),
val unloading: List<RoutePhotoDto> = emptyList(), val unloading: List<RoutePhotoDto> = emptyList(),
@@ -336,6 +354,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(
@@ -343,6 +362,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(
@@ -70,6 +70,9 @@ class DriverSyncRepository(
) )
} }
suspend fun hasBootstrapCache(date: String?): Boolean =
dao.bootstrap(date ?: currentDateFallback()) != null
suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? { suspend fun routeFromCache(routeId: String): CachedValue<RouteResponse>? {
val cached = dao.route(routeId) ?: return null val cached = dao.route(routeId) ?: return null
return CachedValue( return CachedValue(
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.withTimeoutOrNull
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
class StartupOfflineFallbackLoader(
private val timeoutMillis: Long = StartupSessionPolicy.OFFLINE_FALLBACK_MILLIS,
) {
suspend fun <T> load(
onlineLoad: suspend () -> T,
offlineLoad: suspend () -> T?,
): T {
val onlineValue = withTimeoutOrNull(timeoutMillis) {
onlineLoad()
}
if (onlineValue != null) {
return onlineValue
}
return offlineLoad()
?: throw IOException(
"Serwer nie odpowiedział w ciągu ${StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS} sekund " +
"i w telefonie nie ma zapisanych danych offline.",
)
}
}
@@ -63,12 +63,26 @@ class DispatchSheetUploadOutbox(
} }
suspend fun discard(clientRequestId: String) { suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(DispatchSheetUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload -> dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete() File(upload.localPath).delete()
dao.delete(upload.clientRequestId) dao.delete(upload.clientRequestId)
} }
} }
suspend fun retry(clientRequestId: String) {
val existing = dao.find(clientRequestId) ?: return
dao.upsert(
existing.copy(
status = PhotoUploadStatus.Pending.storageValue,
progress = 0,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientRequestId)
}
private fun enqueueWorker(clientRequestId: String) { private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>() val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId)) .setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -68,6 +69,7 @@ class DispatchSheetUploadWorker(
dao.delete(clientRequestId) dao.delete(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -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 }
} }
} }
} }
@@ -36,6 +36,24 @@ class OfflineOutboxManager(private val context: Context) {
} }
} }
suspend fun repairOperationalQueues() {
val photoOutbox = PhotoUploadOutbox(context)
val dispatchSheetOutbox = DispatchSheetUploadOutbox(context)
val routeActionOutbox = RouteActionOutbox(context)
database.photoUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { photoOutbox.retry(it.clientRequestId) }
database.dispatchSheetUploadDao().allUnsent()
.filter { shouldRepairPhotoUpload(it.status, it.lastError) }
.forEach { dispatchSheetOutbox.retry(it.clientRequestId) }
database.routeActionDao().allUnsent()
.filter { shouldRepairRouteAction(it.status, it.lastError) }
.forEach { routeActionOutbox.retry(it.clientActionId) }
}
suspend fun clearAll() { suspend fun clearAll() {
val photos = database.photoUploadDao().allRecords() val photos = database.photoUploadDao().allRecords()
val dispatchSheets = database.dispatchSheetUploadDao().allRecords() val dispatchSheets = database.dispatchSheetUploadDao().allRecords()
@@ -61,3 +79,31 @@ class OfflineOutboxManager(private val context: Context) {
database.routePointDao().deleteAll() database.routePointDao().deleteAll()
} }
} }
internal fun isWorkerCancellationFailure(lastError: String?): Boolean =
lastError?.let { error ->
error.contains("Job was cancelled", ignoreCase = true) ||
error.contains("CancellationException", ignoreCase = true)
} == true
internal fun isPhotoDependencyFailure(lastError: String?): Boolean =
lastError?.contains("Wymagane zdjęcie nie zostało wysłane", ignoreCase = true) == true
internal fun shouldRepairPhotoUpload(status: String, lastError: String?): Boolean =
status in setOf(
PhotoUploadStatus.Pending.storageValue,
PhotoUploadStatus.Uploading.storageValue,
PhotoUploadStatus.Verifying.storageValue,
PhotoUploadStatus.FailedRetryable.storageValue,
) || (status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
internal fun shouldRepairRouteAction(status: String, lastError: String?): Boolean =
status in setOf(
RouteActionStatus.Pending,
RouteActionStatus.WaitingForPhotos,
RouteActionStatus.Syncing,
RouteActionStatus.FailedRetryable,
) || (
status == RouteActionStatus.FailedPermanent &&
(isWorkerCancellationFailure(lastError) || isPhotoDependencyFailure(lastError))
)
@@ -16,6 +16,7 @@ import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadOutbox( class PhotoUploadOutbox(
private val context: Context, private val context: Context,
@@ -74,6 +75,7 @@ class PhotoUploadOutbox(
} }
suspend fun discard(clientRequestId: String) { suspend fun discard(clientRequestId: String) {
workManager.cancelUniqueWork(PhotoUploadWorker.uniqueWorkName(clientRequestId))
dao.find(clientRequestId)?.let { upload -> dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete() File(upload.localPath).delete()
dao.delete(upload.clientRequestId) dao.delete(upload.clientRequestId)
@@ -81,10 +83,13 @@ class PhotoUploadOutbox(
} }
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) { suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
dao.confirmedForRoute(route.id).forEach { upload -> dao.allRecords()
File(upload.localPath).delete() .filter { it.routeId == route.id }
dao.delete(upload.clientRequestId) .filter { upload ->
upload.status == PhotoUploadStatus.Confirmed.storageValue ||
route.photos.orEmpty().any { serverPhoto -> serverPhotoConfirmsUpload(upload, serverPhoto) }
} }
.forEach { upload -> discard(upload.clientRequestId) }
} }
private fun enqueueWorker(clientRequestId: String) { private fun enqueueWorker(clientRequestId: String) {
@@ -118,3 +123,9 @@ class PhotoUploadOutbox(
return digest.digest().joinToString("") { "%02x".format(it) } return digest.digest().joinToString("") { "%02x".format(it) }
} }
} }
internal fun serverPhotoConfirmsUpload(upload: PhotoUploadEntity, serverPhoto: RoutePhotoDto): Boolean =
serverPhoto.clientRequestId == upload.clientRequestId && (
serverPhoto.contentSha256.isNullOrBlank() ||
serverPhoto.contentSha256.equals(upload.contentSha256, ignoreCase = true)
)
@@ -8,6 +8,7 @@ import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
@@ -70,6 +71,7 @@ class PhotoUploadWorker(
enqueueRouteActionsWaitingForPhoto(clientRequestId) enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -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,
) )
@@ -31,17 +31,40 @@ class RouteActionOutbox(
dao.delete(clientActionId) dao.delete(clientActionId)
} }
suspend fun enqueueStart(routeId: String, loadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun retry(clientActionId: String) {
enqueue(RouteActionType.Start, routeId, loadingWeight, photoClientRequestIds) val existing = dao.find(clientActionId) ?: return
dao.upsert(
existing.copy(
status = RouteActionStatus.Pending,
lastError = null,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)
enqueueWorker(clientActionId)
}
suspend fun enqueueFinish(routeId: String, unloadingWeight: Double?, photoClientRequestIds: List<String>): RouteActionEntity = suspend fun enqueueStart(
enqueue(RouteActionType.Finish, routeId, unloadingWeight, 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>,
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(
@@ -51,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)
@@ -7,6 +7,7 @@ import com.google.gson.Gson
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.FinishRouteBody
import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.RouteActionResponse
import pl.firmatpp.kierowca.data.model.StartRouteBody import pl.firmatpp.kierowca.data.model.StartRouteBody
@@ -75,6 +76,7 @@ class RouteActionWorker(
DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -114,6 +116,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(
@@ -123,6 +126,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}")
@@ -8,6 +8,7 @@ import kotlinx.coroutines.delay
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.model.RoutePointBatchBody import pl.firmatpp.kierowca.data.model.RoutePointBatchBody
import pl.firmatpp.kierowca.data.model.RoutePointDto import pl.firmatpp.kierowca.data.model.RoutePointDto
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
@@ -52,6 +53,7 @@ class RoutePointWorker(
} }
val throwable = outcome.exceptionOrNull() ?: return Result.retry() val throwable = outcome.exceptionOrNull() ?: return Result.retry()
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -65,9 +67,11 @@ class RoutePointWorker(
"retryable" to error.retryable, "retryable" to error.retryable,
), ),
) )
if (shouldStopRouteTrackingAfterPointError(error.kind, error.code)) {
ActiveRouteTrackingService.stop(applicationContext, routeId)
}
if (isInactiveRoutePointError(error.code)) { if (isInactiveRoutePointError(error.code)) {
dao.delete(pointIds) dao.delete(pointIds)
ActiveRouteTrackingService.stop(applicationContext, routeId)
continue continue
} }
@@ -103,3 +107,6 @@ class RoutePointWorker(
} }
internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS" internal fun isInactiveRoutePointError(code: String?): Boolean = code == "ROUTE_POINTS_INVALID_STATUS"
internal fun shouldStopRouteTrackingAfterPointError(kind: ApiErrorKind, code: String?): Boolean =
kind == ApiErrorKind.Auth || isInactiveRoutePointError(code)
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.domain
import kotlin.math.roundToInt import kotlin.math.roundToInt
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
data class RouteCardDisplay( data class RouteCardDisplay(
val id: String, val id: String,
@@ -32,7 +33,7 @@ object RouteDisplayMapper {
destination = route.destinationName.ifBlank { "Cel" }, destination = route.destinationName.ifBlank { "Cel" },
contractLabel = contract, contractLabel = contract,
time = route.startsAt.ifBlank { "--:--" }, time = route.startsAt.ifBlank { "--:--" },
status = route.status, status = route.driverLifecycleStatus(),
distanceLabel = distance, distanceLabel = distance,
) )
} }
@@ -0,0 +1,51 @@
package pl.firmatpp.kierowca.domain
data class StartupSessionMessage(
val title: String,
val detail: String,
)
object StartupSessionPolicy {
const val OFFLINE_BUTTON_DELAY_SECONDS = 4
const val OFFLINE_FALLBACK_SECONDS = 15
const val OFFLINE_FALLBACK_MILLIS = OFFLINE_FALLBACK_SECONDS * 1_000L
fun remainingSeconds(elapsedSeconds: Int): Int =
(OFFLINE_FALLBACK_SECONDS - elapsedSeconds).coerceAtLeast(0)
fun shouldShowOfflineButton(elapsedSeconds: Int, offlineDataAvailable: Boolean): Boolean =
offlineDataAvailable && elapsedSeconds >= OFFLINE_BUTTON_DELAY_SECONDS
fun message(elapsedSeconds: Int): StartupSessionMessage = when {
elapsedSeconds < 3 -> StartupSessionMessage(
title = "Sprawdzamy zapisaną sesję",
detail = "Odczytujemy bezpieczne logowanie kierowcy.",
)
elapsedSeconds < 6 -> StartupSessionMessage(
title = "Potwierdzamy logowanie",
detail = "Łączymy telefon z serwerem.",
)
elapsedSeconds < 9 -> StartupSessionMessage(
title = "Pobieramy aktualne kursy",
detail = "Synchronizujemy plan dnia i ustawienia aplikacji.",
)
elapsedSeconds < 12 -> StartupSessionMessage(
title = "To trwa dłużej niż zwykle",
detail = "Przy słabym zasięgu skorzystamy z danych zapisanych w telefonie.",
)
else -> StartupSessionMessage(
title = "Przygotowujemy tryb offline",
detail = "Za chwilę pokażemy ostatnie zapisane kursy.",
)
}
fun countdownLabel(elapsedSeconds: Int): String {
val remaining = remainingSeconds(elapsedSeconds)
return if (remaining > 0) {
"Tryb offline najpóźniej za $remaining s"
} else {
"Otwieramy zapisane dane offline"
}
}
}
@@ -13,6 +13,7 @@ import androidx.work.workDataOf
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.SyncScopeDto import pl.firmatpp.kierowca.data.model.SyncScopeDto
@@ -79,6 +80,7 @@ class DriverSyncWorker(
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
AppDiagnostics.reportNonFatal( AppDiagnostics.reportNonFatal(
throwable = throwable, throwable = throwable,
@@ -26,6 +26,7 @@ import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.rethrowIfCancellation
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import retrofit2.HttpException import retrofit2.HttpException
@@ -75,6 +76,7 @@ class NewRouteNotificationWorker(
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification) NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
throwable.rethrowIfCancellation()
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
Result.success() Result.success()
} else { } else {
@@ -136,6 +136,13 @@ class ActiveRouteTrackingService : Service(), LocationListener {
override fun onDestroy() { override fun onDestroy() {
runCatching { locationManager.removeUpdates(this) } runCatching { locationManager.removeUpdates(this) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
getSystemService(NotificationManager::class.java).cancel(NOTIFICATION_ID)
getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE) getSharedPreferences(TRACKING_PREFERENCES, Context.MODE_PRIVATE)
.edit() .edit()
.remove(ACTIVE_ROUTE_ID) .remove(ACTIVE_ROUTE_ID)
@@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.ui
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -12,9 +13,13 @@ import android.location.LocationManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.core.tween
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
@@ -69,6 +74,7 @@ import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
@@ -115,6 +121,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -143,6 +150,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import java.util.Locale import java.util.Locale
import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
@@ -150,6 +158,7 @@ import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.NavigationPointDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
@@ -159,8 +168,10 @@ import pl.firmatpp.kierowca.diagnostics.DiagnosticSeverity
import pl.firmatpp.kierowca.sync.LiveSyncConnectionState import pl.firmatpp.kierowca.sync.LiveSyncConnectionState
import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.OtpCodeExtractor
import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.domain.StartupSessionPolicy
import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.AppThemeMode
import pl.firmatpp.kierowca.ui.theme.TppTheme import pl.firmatpp.kierowca.ui.theme.TppTheme
import pl.firmatpp.kierowca.update.PlayAppUpdateState
@Composable @Composable
fun DriverApp( fun DriverApp(
@@ -168,8 +179,27 @@ fun DriverApp(
initialRouteId: String? = null, initialRouteId: String? = null,
initialRouteTarget: String? = null, initialRouteTarget: String? = null,
initialLeaveRequestId: String? = null, initialLeaveRequestId: String? = null,
playAppUpdateState: PlayAppUpdateState = PlayAppUpdateState(),
onStartAppUpdate: (required: Boolean, playStoreUrl: String?) -> Unit = { _, _ -> },
onCompleteAppUpdate: () -> Unit = {},
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val updateUi = appUpdateBannerUi(
currentVersionCode = BuildConfig.VERSION_CODE,
latestVersionCode = state.appUpdate?.latestVersionCode,
latestVersionName = state.appUpdate?.latestVersionName,
minimumSupportedVersionCode = state.appUpdate?.minimumSupportedVersionCode ?: 0,
playUpdateAvailable = playAppUpdateState.updateAvailable,
playAvailableVersionCode = playAppUpdateState.availableVersionCode,
updateDownloaded = playAppUpdateState.downloaded,
)
val runUpdate = {
if (updateUi?.downloaded == true) {
onCompleteAppUpdate()
} else {
onStartAppUpdate(updateUi?.required == true, state.appUpdate?.playStoreUrl)
}
}
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current val context = LocalContext.current
var appInForeground by remember { mutableStateOf(true) } var appInForeground by remember { mutableStateOf(true) }
@@ -217,8 +247,10 @@ fun DriverApp(
} }
} }
SmsUserConsentEffect( SmsUserConsentEffect(
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp, enabled = state.otpRequestPending ||
onCode = viewModel::updateOtpCode, (state.screen == DriverScreen.Otp && !state.loading),
requestId = state.otpConsentRequestId,
onCode = viewModel::updateOtpCodeFromSms,
) )
LaunchedEffect(initialRouteId, initialRouteTarget) { LaunchedEffect(initialRouteId, initialRouteTarget) {
viewModel.openRouteFromNotification(initialRouteId, initialRouteTarget) viewModel.openRouteFromNotification(initialRouteId, initialRouteTarget)
@@ -228,12 +260,37 @@ fun DriverApp(
viewModel.openLeaveRequest(initialLeaveRequestId) viewModel.openLeaveRequest(initialLeaveRequestId)
} }
} }
LaunchedEffect(state.screen, state.notifyNewRoutes, state.notificationPermissionDenied, appInForeground) {
if (!appInForeground || state.screen != DriverScreen.Profile || !state.notifyNewRoutes) return@LaunchedEffect
if (canPostNotifications(context)) {
if (state.notificationPermissionDenied) {
viewModel.clearNotificationPermissionWarning()
}
return@LaunchedEffect
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPostNotificationsRuntimePermission(context)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.markNotificationPermissionBlocked()
}
}
Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) { Box(Modifier.fillMaxSize().background(TppTheme.colors.surface)) {
when (state.screen) { when (state.screen) {
DriverScreen.Initializing -> StartupScreen() DriverScreen.Initializing -> StartupScreen(
offlineDataAvailable = state.startupOfflineAvailable,
onUseOfflineNow = viewModel::useOfflineStartupDataNow,
)
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp) DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back) DriverScreen.Otp -> OtpScreen(
state = state,
onCodeChange = viewModel::updateOtpCode,
onSubmit = viewModel::verifyOtp,
onResend = { viewModel.requestOtp(state.phone) },
onBack = viewModel::back,
)
DriverScreen.Routes -> RoutesScreen( DriverScreen.Routes -> RoutesScreen(
state = state, state = state,
onRefresh = viewModel::refreshRoutesSilently, onRefresh = viewModel::refreshRoutesSilently,
@@ -289,13 +346,15 @@ 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::deleteConfirmedUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute, onSubmit = if (routeStageRequirementIsVisible(state.loadingWeightRequirement)) viewModel::openLoadingWeight else viewModel::submitStartRoute,
) )
@@ -306,13 +365,15 @@ 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::deleteConfirmedUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitStartRoute, onSubmit = viewModel::submitStartRoute,
) )
@@ -323,13 +384,15 @@ 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::deleteConfirmedUpload, onDeleteUpload = viewModel::deletePhotoUpload,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
onSubmit = viewModel::submitFinishRoute, onSubmit = viewModel::submitFinishRoute,
) )
@@ -372,11 +435,99 @@ fun DriverApp(
CircularProgressIndicator(color = TppTheme.colors.forest) CircularProgressIndicator(color = TppTheme.colors.forest)
} }
} }
if (updateUi != null && !updateUi.required) {
AppUpdateBanner(
update = updateUi,
onUpdate = runUpdate,
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
if (updateUi?.required == true) {
AlertDialog(
onDismissRequest = {},
title = {
Text(
"Wymagana aktualizacja",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
)
},
text = { Text(updateUi.message, color = TppTheme.colors.muted) },
confirmButton = {
Button(
onClick = runUpdate,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
) {
Text(updateUi.actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
},
)
} }
} }
@Composable @Composable
private fun StartupScreen() { private fun AppUpdateBanner(
update: AppUpdateBannerUi,
onUpdate: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer),
border = BorderStroke(1.dp, TppTheme.colors.warningOutline),
shape = MaterialTheme.shapes.medium,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(40.dp).background(Color.White, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.SystemUpdate, contentDescription = null, tint = TppTheme.colors.forest)
}
Text(
update.message,
color = Color(0xFF5F4200),
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Button(
onClick = onUpdate,
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.small,
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
) {
Text(update.actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun StartupScreen(
offlineDataAvailable: Boolean,
onUseOfflineNow: () -> Unit,
) {
var elapsedSeconds by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (elapsedSeconds < StartupSessionPolicy.OFFLINE_FALLBACK_SECONDS) {
delay(1_000L)
elapsedSeconds += 1
}
}
val sessionMessage = StartupSessionPolicy.message(elapsedSeconds)
val countdownLabel = StartupSessionPolicy.countdownLabel(elapsedSeconds)
val showOfflineButton = StartupSessionPolicy.shouldShowOfflineButton(elapsedSeconds, offlineDataAvailable)
BoxWithConstraints( BoxWithConstraints(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -401,13 +552,100 @@ private fun StartupScreen() {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
CircularProgressIndicator(color = TppTheme.colors.forest) CircularProgressIndicator(color = TppTheme.colors.forest)
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(22.dp))
Text( Text(
"Sprawdzanie sesji", "URUCHAMIAMY APLIKACJĘ",
color = TppTheme.colors.muted, color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
fontSize = 12.sp,
) )
Spacer(Modifier.height(10.dp))
AnimatedContent(
targetState = sessionMessage,
transitionSpec = {
fadeIn(animationSpec = tween(260)) togetherWith
fadeOut(animationSpec = tween(220))
},
label = "startup-session-message",
) { message ->
Column(
modifier = Modifier.heightIn(min = 72.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
Text(
message.title,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(6.dp))
Text(
message.detail,
modifier = Modifier.fillMaxWidth(),
color = TppTheme.colors.muted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
Spacer(Modifier.height(14.dp))
Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.panel),
border = BorderStroke(1.dp, TppTheme.colors.outline.copy(alpha = 0.72f)),
shape = RoundedCornerShape(6.dp),
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedContent(
targetState = countdownLabel,
transitionSpec = {
fadeIn(animationSpec = tween(180)) togetherWith
fadeOut(animationSpec = tween(140))
},
label = "startup-offline-countdown",
) { label ->
Text(
label,
color = TppTheme.colors.forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(4.dp))
Text(
"Jeśli kursy były wcześniej zapisane w telefonie.",
color = TppTheme.colors.muted,
fontSize = 12.sp,
)
}
}
AnimatedVisibility(
visible = showOfflineButton,
enter = fadeIn(animationSpec = tween(260)),
exit = fadeOut(animationSpec = tween(160)),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onUseOfflineNow,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
border = BorderStroke(1.dp, TppTheme.colors.forest),
colors = ButtonDefaults.outlinedButtonColors(contentColor = TppTheme.colors.forest),
shape = RoundedCornerShape(6.dp),
) {
Text(
"Przejdź do trybu offline teraz",
fontWeight = FontWeight.Bold,
)
}
}
}
} }
} }
} }
@@ -616,8 +854,19 @@ private fun OtpScreen(
state: DriverUiState, state: DriverUiState,
onCodeChange: (String) -> Unit, onCodeChange: (String) -> Unit,
onSubmit: (String) -> Unit, onSubmit: (String) -> Unit,
onResend: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
var nowEpochMillis by remember(state.otpResendAvailableAtEpochMillis) { mutableStateOf(System.currentTimeMillis()) }
val resendDelaySeconds = otpResendDelaySeconds(state.otpResendAvailableAtEpochMillis, nowEpochMillis)
LaunchedEffect(state.otpResendAvailableAtEpochMillis) {
while (nowEpochMillis < state.otpResendAvailableAtEpochMillis) {
delay(1_000L)
nowEpochMillis = System.currentTimeMillis()
}
}
AuthShell(title = "Kod SMS", subtitle = "Wpisz kod wyslany na ${state.maskedPhone}.", onBack = onBack) { AuthShell(title = "Kod SMS", subtitle = "Wpisz kod wyslany na ${state.maskedPhone}.", onBack = onBack) {
OutlinedTextField( OutlinedTextField(
value = state.otpCode, value = state.otpCode,
@@ -629,7 +878,20 @@ private fun OtpScreen(
) )
ErrorText(state.error) ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp)) Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp))
PrimaryButton("Zaloguj") { onSubmit(state.otpCode) } PrimaryButton(
label = "Zaloguj",
enabled = isRetrievedOtpCode(state.otpCode) && !state.loading,
) { onSubmit(state.otpCode) }
TextButton(
onClick = onResend,
enabled = resendDelaySeconds == 0 && !state.loading,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(
if (resendDelaySeconds > 0) "Wyślij kod ponownie za $resendDelaySeconds s"
else "Wyślij kod ponownie",
)
}
} }
} }
@@ -1866,7 +2128,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
) )
} }
} }
if (hasFailureDetails || canRetryPhotoUpload(upload.status)) { if (hasFailureDetails || canRetryPhotoUpload(upload.status, upload.lastError)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
if (hasFailureDetails) { if (hasFailureDetails) {
IconButton( IconButton(
@@ -1884,7 +2146,7 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
) )
} }
} }
if (canRetryPhotoUpload(upload.status)) { if (canRetryPhotoUpload(upload.status, upload.lastError)) {
Button( Button(
onClick = { onRetryUpload(upload) }, onClick = { onRetryUpload(upload) },
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
@@ -2021,7 +2283,8 @@ private fun routeListTitle(selectedDate: String): String {
@Composable @Composable
private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) { private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
val display = RouteDisplayMapper.toCard(route) val display = RouteDisplayMapper.toCard(route)
val stripColor = routeStatusColor(route.status) val driverStatus = route.driverLifecycleStatus()
val stripColor = routeStatusColor(driverStatus)
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -2043,7 +2306,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) { Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) {
if (compactWidth) { if (compactWidth) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
StatusChip(route.status, stripColor) StatusChip(driverStatus, stripColor)
Text( Text(
"Kontrakt: #${display.contractLabel.trimStart('#')}", "Kontrakt: #${display.contractLabel.trimStart('#')}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -2057,7 +2320,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
StatusChip(route.status, stripColor) StatusChip(driverStatus, stripColor)
Text( Text(
"Kontrakt: #${display.contractLabel.trimStart('#')}", "Kontrakt: #${display.contractLabel.trimStart('#')}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -2238,13 +2501,18 @@ private fun ProfileScreen(
Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall) Text("Zmiana powiadomień wymaga połączenia z internetem.", color = TppTheme.colors.muted, style = MaterialTheme.typography.bodySmall)
} }
if (state.notificationPermissionDenied) { if (state.notificationPermissionDenied) {
Text(
"Android blokuje powiadomienia dla tej aplikacji. Token FCM może być zarejestrowany, ale powiadomienie nie pojawi się na telefonie, dopóki nie włączysz zgody systemowej.",
color = TppTheme.colors.error,
style = MaterialTheme.typography.bodySmall,
)
Button( Button(
onClick = onOpenNotificationSettings, onClick = onOpenNotificationSettings,
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.navy),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
) { ) {
Text("Przejdź do ustawień", fontWeight = FontWeight.Bold) Text("Otwórz ustawienia powiadomień", fontWeight = FontWeight.Bold)
} }
} }
HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f)) HorizontalDivider(color = TppTheme.colors.outline.copy(alpha = 0.55f))
@@ -2605,10 +2873,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,
@@ -2618,33 +2888,40 @@ private fun RouteStageScreen(
val context = LocalContext.current val context = LocalContext.current
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 }
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, stageUploads.size) val usableStageUploads = routeStageUsableUploads(stageUploads)
val attachmentCount = visiblePhotoAttachmentCount(stagePhotos.size, usableStageUploads.size)
val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement val photoRequirement = if (stage == "loading") state.loadingPhotoRequirement else state.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement val weightRequirement = if (stage == "loading") state.loadingWeightRequirement else state.unloadingWeightRequirement
val submitBlocker = when { val submitBlocker = when {
!showWeightInput -> loadingPhotosSubmitBlocker( !showWeightInput -> loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = usableStageUploads.size,
photoRequirement = photoRequirement, photoRequirement = photoRequirement,
) )
stage == "loading" -> loadingWeightSubmitBlocker( stage == "loading" -> loadingWeightSubmitBlocker(
weightText = state.routeStageWeightText, weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = usableStageUploads.size,
weightRequirement = weightRequirement, weightRequirement = weightRequirement,
photoRequirement = photoRequirement, photoRequirement = photoRequirement,
) )
else -> routeStageSubmitBlocker( else -> routeStageSubmitBlocker(
weightText = state.routeStageWeightText, weightText = state.routeStageWeightText,
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = usableStageUploads.size,
weightRequirement = weightRequirement, weightRequirement = weightRequirement,
photoRequirement = photoRequirement, photoRequirement = photoRequirement,
) )
} }
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 ->
@@ -2684,7 +2961,7 @@ private fun RouteStageScreen(
bottomBar = { bottomBar = {
RouteStageSubmitBar( RouteStageSubmitBar(
submitLabel = submitLabel, submitLabel = submitLabel,
submitBlocker = submitBlocker, submitBlocker = effectiveSubmitBlocker,
onSubmit = onSubmit, onSubmit = onSubmit,
) )
}, },
@@ -2725,16 +3002,14 @@ private fun RouteStageScreen(
label = { Text(weightLabel) }, label = { Text(weightLabel) },
trailingIcon = { trailingIcon = {
Text( Text(
"t", "t / kg",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
}, },
supportingText = { supportingText = {
if (weightBlocker != null) { Text(weightBlocker ?: routeWeightInputHelpText(state.routeStageWeightText))
Text(weightBlocker)
}
}, },
isError = weightBlocker != null, isError = weightBlocker != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
@@ -2743,6 +3018,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)) {
@@ -3124,7 +3411,7 @@ private fun RouteLifecycleSection(
) )
} }
if (route.status == "W TRAKCIE" && !routeProgressNotificationEnabled) { if (route.driverLifecycleStatus() == "W TRAKCIE" && !routeProgressNotificationEnabled) {
Text( Text(
"Powiadomienie działa w trybie minimalnym.", "Powiadomienie działa w trybie minimalnym.",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
@@ -3235,7 +3522,8 @@ private fun RouteFlowStepItem(step: RouteFlowStepUi, modifier: Modifier = Modifi
private fun routeFlowStepColor(state: RouteFlowStepState): Color = private fun routeFlowStepColor(state: RouteFlowStepState): Color =
when (state) { when (state) {
RouteFlowStepState.Todo -> TppTheme.colors.muted RouteFlowStepState.Todo -> TppTheme.colors.muted
RouteFlowStepState.LocalComplete -> TppTheme.colors.navy RouteFlowStepState.ServerSaved,
RouteFlowStepState.InProgress -> TppTheme.colors.navy
RouteFlowStepState.Syncing -> TppTheme.colors.containerGreen RouteFlowStepState.Syncing -> TppTheme.colors.containerGreen
RouteFlowStepState.Confirmed -> TppTheme.colors.forest RouteFlowStepState.Confirmed -> TppTheme.colors.forest
RouteFlowStepState.NeedsAttention -> TppTheme.colors.error RouteFlowStepState.NeedsAttention -> TppTheme.colors.error
@@ -3245,7 +3533,8 @@ private fun routeFlowStepColor(state: RouteFlowStepState): Color =
private fun routeFlowStepBackground(state: RouteFlowStepState): Color = private fun routeFlowStepBackground(state: RouteFlowStepState): Color =
when (state) { when (state) {
RouteFlowStepState.Todo -> TppTheme.colors.panel.copy(alpha = 0.62f) RouteFlowStepState.Todo -> TppTheme.colors.panel.copy(alpha = 0.62f)
RouteFlowStepState.LocalComplete -> Color(0xFFE9F2FF) RouteFlowStepState.ServerSaved,
RouteFlowStepState.InProgress -> Color(0xFFE9F2FF)
RouteFlowStepState.Syncing -> TppTheme.colors.successContainer.copy(alpha = 0.75f) RouteFlowStepState.Syncing -> TppTheme.colors.successContainer.copy(alpha = 0.75f)
RouteFlowStepState.Confirmed -> TppTheme.colors.successContainer RouteFlowStepState.Confirmed -> TppTheme.colors.successContainer
RouteFlowStepState.NeedsAttention -> TppTheme.colors.warningContainer RouteFlowStepState.NeedsAttention -> TppTheme.colors.warningContainer
@@ -3254,7 +3543,8 @@ private fun routeFlowStepBackground(state: RouteFlowStepState): Color =
private fun routeFlowStepIcon(state: RouteFlowStepState): ImageVector = private fun routeFlowStepIcon(state: RouteFlowStepState): ImageVector =
when (state) { when (state) {
RouteFlowStepState.Todo -> Icons.Outlined.Info RouteFlowStepState.Todo -> Icons.Outlined.Info
RouteFlowStepState.LocalComplete -> Icons.Outlined.LocalShipping RouteFlowStepState.ServerSaved -> Icons.Outlined.CheckCircle
RouteFlowStepState.InProgress -> Icons.Outlined.LocalShipping
RouteFlowStepState.Syncing -> Icons.Outlined.CloudUpload RouteFlowStepState.Syncing -> Icons.Outlined.CloudUpload
RouteFlowStepState.Confirmed -> Icons.Outlined.CheckCircle RouteFlowStepState.Confirmed -> Icons.Outlined.CheckCircle
RouteFlowStepState.NeedsAttention -> Icons.Outlined.Info RouteFlowStepState.NeedsAttention -> Icons.Outlined.Info
@@ -3304,7 +3594,8 @@ private fun leaveConflictCountLabel(count: Int): String =
@Composable @Composable
private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) { private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status) val driverStatus = route.driverLifecycleStatus()
val stripColor = routeStatusColor(driverStatus)
val routeDate = route.routeDate ?: selectedDate val routeDate = route.routeDate ?: selectedDate
Card( Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -3319,7 +3610,7 @@ private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavig
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
StatusChip(route.status, stripColor) StatusChip(driverStatus, stripColor)
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
RouteDateStatusChip(routeDate) RouteDateStatusChip(routeDate)
} }
@@ -3430,7 +3721,10 @@ private fun RoutePointBlock(
Text(subtitle, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyLarge) Text(subtitle, color = TppTheme.colors.muted, style = MaterialTheme.typography.bodyLarge)
} }
} }
if (navigationPoint?.hasCoordinates() == true) { if (
navigationPoint != null &&
hasValidNavigationCoordinates(navigationPoint.latitude, navigationPoint.longitude)
) {
IconButton( IconButton(
onClick = { onNavigate(navigationPoint) }, onClick = { onNavigate(navigationPoint) },
modifier = Modifier.size(42.dp).background(TppTheme.colors.forest, RoundedCornerShape(21.dp)), modifier = Modifier.size(42.dp).background(TppTheme.colors.forest, RoundedCornerShape(21.dp)),
@@ -3500,6 +3794,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,
@@ -3510,6 +3805,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,
@@ -3521,6 +3817,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,
@@ -3536,6 +3833,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>,
@@ -3569,6 +3867,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
@@ -3673,7 +4003,7 @@ private fun PhotoGrid(
) )
is PhotoGridItem.Upload -> PendingPhotoTile( is PhotoGridItem.Upload -> PendingPhotoTile(
upload = item.upload, upload = item.upload,
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true, isDeleting = (item.upload.serverPhotoId ?: item.upload.clientRequestId) in deletingPhotoIds,
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) }, onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) }, onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
onRetry = onRetryUpload, onRetry = onRetryUpload,
@@ -3823,6 +4153,7 @@ private fun PendingPhotoTile(
) { ) {
val status = upload.statusType val status = upload.statusType
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
val canDeleteUpload = canDeleteServerPhoto || canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError) val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
var showFailureDetails by remember { mutableStateOf(false) } var showFailureDetails by remember { mutableStateOf(false) }
if (showFailureDetails) { if (showFailureDetails) {
@@ -3878,7 +4209,7 @@ private fun PendingPhotoTile(
Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) Text("Usuwam", color = TppTheme.colors.muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
} }
} }
if (!readOnly && canDeleteServerPhoto) { if (!readOnly && canDeleteUpload) {
IconButton( IconButton(
onClick = { onDelete(upload) }, onClick = { onDelete(upload) },
enabled = !isDeleting, enabled = !isDeleting,
@@ -3916,13 +4247,13 @@ private fun PendingPhotoTile(
) )
} }
} }
if (!readOnly && canRetryPhotoUpload(upload.status)) { if (!readOnly && canRetryPhotoUpload(upload.status, upload.lastError)) {
IconButton( IconButton(
onClick = { onRetry(upload) }, onClick = { onRetry(upload) },
enabled = !isDeleting, enabled = !isDeleting,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp) .padding(top = if (canDeleteUpload) 56.dp else 8.dp, end = 8.dp)
.size(40.dp) .size(40.dp)
.background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp)) .background(TppTheme.colors.surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
.border(1.dp, TppTheme.colors.forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), .border(1.dp, TppTheme.colors.forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
@@ -4153,9 +4484,10 @@ private fun PhotoPreviewErrorOverlay() {
} }
@Composable @Composable
private fun PrimaryButton(label: String, onClick: () -> Unit) { private fun PrimaryButton(label: String, enabled: Boolean = true, onClick: () -> Unit) {
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
@@ -4220,24 +4552,29 @@ private fun createCameraUri(context: Context): Uri {
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
} }
private fun NavigationPointDto.hasCoordinates(): Boolean = latitude != null && longitude != null
private fun openNavigation(context: Context, point: NavigationPointDto) { private fun openNavigation(context: Context, point: NavigationPointDto) {
val latitude = point.latitude ?: return val candidates = navigationIntentCandidates(point.latitude, point.longitude, point.label)
val longitude = point.longitude ?: return
val googleMapsIntent = Intent(Intent.ACTION_VIEW, Uri.parse("google.navigation:q=$latitude,$longitude"))
.setPackage("com.google.android.apps.maps")
if (googleMapsIntent.resolveActivity(context.packageManager) != null) { for (candidate in candidates) {
context.startActivity(googleMapsIntent) val intent = Intent(Intent.ACTION_VIEW, Uri.parse(candidate.uri)).apply {
candidate.packageName?.let(::setPackage)
}
try {
context.startActivity(intent)
return return
} catch (_: ActivityNotFoundException) {
// Try the next navigation provider.
} catch (_: SecurityException) {
// The provider is unavailable to this app; use the next fallback.
}
} }
val label = Uri.encode(point.label?.takeIf { it.isNotBlank() } ?: "Cel") Toast.makeText(
val geoIntent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$latitude,$longitude($label)")) context,
if (geoIntent.resolveActivity(context.packageManager) != null) { "Nie znaleziono aplikacji, która może otworzyć nawigację.",
context.startActivity(geoIntent) Toast.LENGTH_LONG,
} ).show()
} }
private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList { private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList {
@@ -4302,28 +4639,48 @@ private fun bestLastKnownLocation(context: Context): Location? {
} }
@Composable @Composable
private fun SmsUserConsentEffect(enabled: Boolean, onCode: (String) -> Unit) { private fun SmsUserConsentEffect(enabled: Boolean, requestId: Long, onCode: (Long, String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
var listenGeneration by remember(requestId) { mutableStateOf(0) }
var launchedRequestId by remember { mutableStateOf<Long?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val completedRequestId = launchedRequestId
var codeDelivered = false
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE).orEmpty() val message = result.data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE).orEmpty()
OtpCodeExtractor.extract(message)?.let(onCode) if (completedRequestId != null) {
OtpCodeExtractor.extract(message)?.let { code ->
codeDelivered = true
onCode(completedRequestId, code)
}
}
}
if (!codeDelivered && enabled && completedRequestId == requestId) {
listenGeneration += 1
} }
} }
DisposableEffect(enabled, context) { LaunchedEffect(enabled, requestId, listenGeneration) {
if (!enabled) return@DisposableEffect onDispose { } if (enabled && requestId > 0L) {
SmsRetriever.getClient(context).startSmsUserConsent(null) SmsRetriever.getClient(context).startSmsUserConsent(null)
}
}
DisposableEffect(enabled, context, requestId) {
if (!enabled) return@DisposableEffect onDispose { }
val receiver = object : BroadcastReceiver() { val receiver = object : BroadcastReceiver() {
override fun onReceive(receiverContext: Context?, intent: Intent?) { override fun onReceive(receiverContext: Context?, intent: Intent?) {
if (intent?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return if (intent?.action != SmsRetriever.SMS_RETRIEVED_ACTION) return
val status = intent.smsRetrieverStatus() ?: return val status = intent.smsRetrieverStatus() ?: return
if (status.statusCode != CommonStatusCodes.SUCCESS) return when (status.statusCode) {
CommonStatusCodes.SUCCESS -> intent.smsConsentIntent()?.let { consentIntent ->
intent.smsConsentIntent()?.let(launcher::launch) launchedRequestId = requestId
launcher.launch(consentIntent)
}
CommonStatusCodes.TIMEOUT -> listenGeneration += 1
}
} }
} }
@@ -40,7 +40,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
RouteActionType.Start -> { RouteActionType.Start -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "W TRAKCIE", status = "W TRAKCIE",
driverStatus = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
loadingNotes = action.notes,
trackingStatus = "active", trackingStatus = "active",
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
) )
@@ -49,7 +51,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
status = "ZAKOŃCZONA", status = "ZAKOŃCZONA",
driverStatus = "ZAKOŃCZONA",
unloadingWeight = action.weight, unloadingWeight = action.weight,
unloadingNotes = action.notes,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = action.occurredAt, completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(), photos = projectedRoute.safePhotos(),
@@ -1,24 +1,114 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.io.File import java.io.File
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URLEncoder
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.abs
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val routeWeightKilogramInputThreshold = BigDecimal("1000")
private const val routeWeightMaxTons = 999.999
const val routeStageNotesMaxLength = 2000
private const val googleMapsPackage = "com.google.android.apps.maps"
data class NavigationIntentCandidate(
val uri: String,
val packageName: String? = null,
)
data class AppUpdateBannerUi(
val message: String,
val actionLabel: String,
val required: Boolean,
val downloaded: Boolean,
)
fun appUpdateBannerUi(
currentVersionCode: Int,
latestVersionCode: Int?,
latestVersionName: String?,
minimumSupportedVersionCode: Int,
playUpdateAvailable: Boolean,
playAvailableVersionCode: Int?,
updateDownloaded: Boolean,
): AppUpdateBannerUi? {
val highestAvailableVersion = listOfNotNull(latestVersionCode, playAvailableVersionCode).maxOrNull()
val required = minimumSupportedVersionCode > 0 && currentVersionCode < minimumSupportedVersionCode
val available = updateDownloaded ||
playUpdateAvailable ||
highestAvailableVersion?.let { it > currentVersionCode } == true ||
required
if (!available) return null
val versionLabel = latestVersionName
?.takeIf { it.isNotBlank() }
?.let { " $it" }
.orEmpty()
val message = when {
updateDownloaded -> "Aktualizacja$versionLabel jest pobrana i gotowa do instalacji."
required -> "Ta wersja aplikacji nie jest już obsługiwana. Zaktualizuj ją, aby kontynuować."
else -> "Dostępna jest nowa wersja aplikacji$versionLabel."
}
return AppUpdateBannerUi(
message = message,
actionLabel = if (updateDownloaded) "Zainstaluj" else "Aktualizuj",
required = required,
downloaded = updateDownloaded,
)
}
fun hasValidNavigationCoordinates(latitude: Double?, longitude: Double?): Boolean =
latitude != null &&
longitude != null &&
latitude.isFinite() &&
longitude.isFinite() &&
latitude in -90.0..90.0 &&
longitude in -180.0..180.0
fun navigationIntentCandidates(
latitude: Double?,
longitude: Double?,
label: String?,
): List<NavigationIntentCandidate> {
if (!hasValidNavigationCoordinates(latitude, longitude)) return emptyList()
val coordinates = "$latitude,$longitude"
val encodedLabel = URLEncoder
.encode(label?.takeIf { it.isNotBlank() } ?: "Cel", Charsets.UTF_8.name())
.replace("+", "%20")
return listOf(
NavigationIntentCandidate(
uri = "google.navigation:q=$coordinates",
packageName = googleMapsPackage,
),
NavigationIntentCandidate(uri = "geo:0,0?q=$coordinates($encodedLabel)"),
NavigationIntentCandidate(uri = "https://www.google.com/maps/dir/?api=1&destination=$coordinates"),
)
}
enum class RouteFlowStepState { enum class RouteFlowStepState {
Todo, Todo,
LocalComplete, ServerSaved,
InProgress,
Syncing, Syncing,
Confirmed, Confirmed,
NeedsAttention, NeedsAttention,
@@ -97,10 +187,10 @@ fun routeTodayInstruction(
val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate val unloadingDate = routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } ?: loadingDate
return when { return when {
route.status == "ZAKOŃCZONA" -> "Kurs zakończony." route.driverLifecycleStatus() == "ZAKOŃCZONA" -> "Kurs zakończony."
loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek." loadingDate == unloadingDate -> "Dziś: załadunek i rozładunek."
today == loadingDate -> "Dziś: załadunek." today == loadingDate -> "Dziś: załadunek."
today == unloadingDate && route.status == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj." today == unloadingDate && route.driverLifecycleStatus() == "ZAPLANOWANA" -> "Dziś: rozładunek. Załadunek możesz potwierdzić dzisiaj."
today == unloadingDate -> "Dziś: rozładunek." today == unloadingDate -> "Dziś: rozładunek."
else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}." else -> "Kurs jest w trasie. Rozładunek: ${routeScheduleDateLabel(routeUnloadingDate(route))}."
} }
@@ -113,7 +203,7 @@ fun canCompleteRouteFromDriverApp(
): Boolean = ): Boolean =
runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE") && route.driverLifecycleStatus() in setOf("ZAPLANOWANA", "W TRAKCIE")
fun canStartRouteFromDriverApp( fun canStartRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
@@ -121,14 +211,14 @@ fun canStartRouteFromDriverApp(
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
routeIsActiveToday(route, selectedDate, today) routeIsActiveToday(route, selectedDate, today)
&& route.status == "ZAPLANOWANA" && route.driverLifecycleStatus() == "ZAPLANOWANA"
fun canFinishRouteFromDriverApp( fun canFinishRouteFromDriverApp(
route: DriverRouteDto, route: DriverRouteDto,
selectedDate: String, selectedDate: String,
today: LocalDate = LocalDate.now(), today: LocalDate = LocalDate.now(),
): Boolean = ): Boolean =
route.status == "W TRAKCIE" route.driverLifecycleStatus() == "W TRAKCIE"
&& runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today && runCatching { LocalDate.parse(selectedDate) }.getOrNull() == today
&& routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today && routeUnloadingDate(route)?.let { runCatching { LocalDate.parse(it) }.getOrNull() } == today
@@ -150,6 +240,33 @@ 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? {
val enteredWeight = weightText.trim().replace(',', '.').toBigDecimalOrNull() ?: return null
val tons = if (enteredWeight.abs() >= routeWeightKilogramInputThreshold) {
enteredWeight.movePointLeft(3)
} else {
enteredWeight
}
return tons.setScale(3, RoundingMode.HALF_UP).toDouble()
}
fun routeWeightInputHelpText(weightText: String): String {
val enteredWeight = weightText.trim().replace(',', '.').toDoubleOrNull()
val tons = parseRouteWeightTons(weightText)
val enteredAsKilograms = enteredWeight != null &&
abs(enteredWeight) >= routeWeightKilogramInputThreshold.toDouble() &&
tons != null
return if (enteredAsKilograms) {
"Wpisano kilogramy — zapiszemy ${String.format(Locale("pl", "PL"), "%.3f", tons)} t."
} else {
"Wpisz tony (np. 23,4). Wartości od 1000 rozpoznajemy jako kilogramy (np. 23400)."
}
}
fun canSubmitRouteStageForm( fun canSubmitRouteStageForm(
weightText: String, weightText: String,
serverPhotoCount: Int, serverPhotoCount: Int,
@@ -188,13 +305,14 @@ fun loadingWeightSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku." loadingPhotosSubmitBlocker(serverPhotoCount, localUploadCount, photoRequirement) != null -> "Dodaj co najmniej jedno zdjęcie załadunku."
else -> null else -> null
} }
@@ -208,13 +326,14 @@ fun routeStageSubmitBlocker(
photoRequirement: String = RouteStageRequirement.Required, photoRequirement: String = RouteStageRequirement.Required,
): String? { ): String? {
val trimmed = weightText.trim() val trimmed = weightText.trim()
val normalizedWeight = trimmed.replace(',', '.').toDoubleOrNull() val normalizedWeight = parseRouteWeightTons(trimmed)
val weightVisible = routeStageRequirementIsVisible(weightRequirement) val weightVisible = routeStageRequirementIsVisible(weightRequirement)
return when { return when {
weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż." weightVisible && routeStageRequirementIsRequired(weightRequirement) && trimmed.isBlank() -> "Podaj tonaż."
weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż." weightVisible && trimmed.isNotBlank() && normalizedWeight == null -> "Podaj poprawny tonaż."
weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera." weightVisible && normalizedWeight != null && normalizedWeight <= 0.0 -> "Tonaż musi być większy od zera."
weightVisible && normalizedWeight != null && normalizedWeight > routeWeightMaxTons -> "Tonaż nie może przekraczać 999,999 t."
routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu." routeStageRequirementIsRequired(photoRequirement) && visiblePhotoAttachmentCount(serverPhotoCount, localUploadCount) <= 0 -> "Dodaj co najmniej jedno zdjęcie etapu."
else -> null else -> null
} }
@@ -264,29 +383,30 @@ fun routeFlowSteps(
val startOverride = routeActionStepState(actions, RouteActionType.Start) val startOverride = routeActionStepState(actions, RouteActionType.Start)
val finishOverride = routeActionStepState(actions, RouteActionType.Finish) val finishOverride = routeActionStepState(actions, RouteActionType.Finish)
val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size val loadingPhotoCount = routePhotosForStage(route.photos, "loading").size
val driverStatus = route.driverLifecycleStatus()
val loadingPhotosState = when { val loadingPhotosState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed startOverride != null -> startOverride
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
loadingPhotoCount > 0 -> RouteFlowStepState.LocalComplete loadingPhotoCount > 0 -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val loadingWeightState = startOverride ?: when { val loadingWeightState = startOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.status == "W TRAKCIE" -> RouteFlowStepState.Confirmed driverStatus == "W TRAKCIE" -> RouteFlowStepState.Confirmed
route.loadingWeight != null -> RouteFlowStepState.LocalComplete route.loadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val transitState = when { val transitState = when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed finishOverride != null -> RouteFlowStepState.InProgress
finishOverride == RouteFlowStepState.NeedsAttention -> RouteFlowStepState.LocalComplete driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
finishOverride == RouteFlowStepState.Syncing -> RouteFlowStepState.LocalComplete startOverride != null -> startOverride
route.status == "W TRAKCIE" -> RouteFlowStepState.LocalComplete driverStatus == "W TRAKCIE" -> RouteFlowStepState.InProgress
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
val unloadingState = finishOverride ?: when { val unloadingState = finishOverride ?: when {
route.status == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed driverStatus == "ZAKOŃCZONA" -> RouteFlowStepState.Confirmed
route.unloadingWeight != null -> RouteFlowStepState.LocalComplete route.unloadingWeight != null -> RouteFlowStepState.ServerSaved
else -> RouteFlowStepState.Todo else -> RouteFlowStepState.Todo
} }
@@ -347,7 +467,8 @@ private fun routeActionStepState(actions: List<RouteActionEntity>, actionType: S
private fun routeFlowStateLabel(state: RouteFlowStepState): String = private fun routeFlowStateLabel(state: RouteFlowStepState): String =
when (state) { when (state) {
RouteFlowStepState.Todo -> "do zrobienia" RouteFlowStepState.Todo -> "do zrobienia"
RouteFlowStepState.LocalComplete -> "uzupełnione lokalnie" RouteFlowStepState.ServerSaved -> "zapisane na serwerze"
RouteFlowStepState.InProgress -> "w trakcie"
RouteFlowStepState.Syncing -> "synchronizacja" RouteFlowStepState.Syncing -> "synchronizacja"
RouteFlowStepState.Confirmed -> "potwierdzone" RouteFlowStepState.Confirmed -> "potwierdzone"
RouteFlowStepState.NeedsAttention -> "wymaga uwagi" RouteFlowStepState.NeedsAttention -> "wymaga uwagi"
@@ -441,6 +562,18 @@ fun routeFactColumns(screenWidthDp: Int): Int =
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int = fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0) serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
fun photoUploadCanSatisfyStage(status: String): Boolean =
status in setOf(
PhotoUploadStatus.Pending.storageValue,
PhotoUploadStatus.Uploading.storageValue,
PhotoUploadStatus.Verifying.storageValue,
PhotoUploadStatus.Confirmed.storageValue,
PhotoUploadStatus.FailedRetryable.storageValue,
)
fun routeStageUsableUploads(uploads: List<PhotoUploadEntity>): List<PhotoUploadEntity> =
uploads.filter { photoUploadCanSatisfyStage(it.status) }
fun queuedPhotoUploadCount(statuses: List<String>): Int = fun queuedPhotoUploadCount(statuses: List<String>): Int =
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" } statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
@@ -452,8 +585,15 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? = fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() } serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean = fun canRetryPhotoUpload(status: String, lastError: String? = null): Boolean =
status == "FAILED_RETRYABLE" status == PhotoUploadStatus.FailedRetryable.storageValue ||
(status == PhotoUploadStatus.FailedPermanent.storageValue && isWorkerCancellationFailure(lastError))
fun canDiscardLocalPhotoUpload(status: String, serverPhotoId: String?): Boolean =
serverPhotoId.isNullOrBlank() && status in setOf(
PhotoUploadStatus.FailedRetryable.storageValue,
PhotoUploadStatus.FailedPermanent.storageValue,
)
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean = fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank() status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
@@ -481,6 +621,14 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
) )
} }
fun routeScreenAfterServerRefresh(currentScreen: DriverScreen, route: DriverRouteDto): DriverScreen =
when {
currentScreen in setOf(DriverScreen.StartRoute, DriverScreen.LoadingWeight) &&
route.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA") -> DriverScreen.Detail
currentScreen == DriverScreen.FinishRoute && route.driverLifecycleStatus() == "ZAKOŃCZONA" -> DriverScreen.Detail
else -> currentScreen
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean = fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation !requirePreciseLocation || hasFineLocation
@@ -22,12 +22,15 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.AppUpdateDto
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.model.driverLifecycleStatus
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -39,6 +42,7 @@ import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionOutbox import pl.firmatpp.kierowca.data.upload.RouteActionOutbox
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
import pl.firmatpp.kierowca.data.upload.isWorkerCancellationFailure
import pl.firmatpp.kierowca.diagnostics.AppDiagnostics import pl.firmatpp.kierowca.diagnostics.AppDiagnostics
import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry import pl.firmatpp.kierowca.diagnostics.DiagnosticEntry
import pl.firmatpp.kierowca.diagnostics.DiagnosticSection import pl.firmatpp.kierowca.diagnostics.DiagnosticSection
@@ -61,8 +65,12 @@ data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
val loading: Boolean = true, val loading: Boolean = true,
val refreshing: Boolean = false, val refreshing: Boolean = false,
val startupOfflineAvailable: Boolean = false,
val phone: String = "", val phone: String = "",
val otpCode: String = "", val otpCode: String = "",
val otpConsentRequestId: Long = 0L,
val otpRequestPending: Boolean = false,
val otpResendAvailableAtEpochMillis: Long = 0L,
val maskedPhone: String = "", val maskedPhone: String = "",
val driver: DriverDto? = null, val driver: DriverDto? = null,
val routes: List<DriverRouteDto> = emptyList(), val routes: List<DriverRouteDto> = emptyList(),
@@ -83,11 +91,13 @@ 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(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val appUpdate: AppUpdateDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(), val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null, val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(), val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
@@ -129,7 +139,7 @@ data class DriverUiState(
val displayRoutes: List<DriverRouteDto> val displayRoutes: List<DriverRouteDto>
get() = projectDriverRoutes(routes, projectionActions) get() = projectDriverRoutes(routes, projectionActions)
.sortedWith( .sortedWith(
compareBy<DriverRouteDto> { route -> if (route.status == "ZAKOŃCZONA") 1 else 0 } compareBy<DriverRouteDto> { route -> if (route.driverLifecycleStatus() == "ZAKOŃCZONA") 1 else 0 }
.thenBy { route -> routeDayOrdering(route, selectedDate) }, .thenBy { route -> routeDayOrdering(route, selectedDate) },
) )
@@ -188,6 +198,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val appPreferencesStore = AppPreferencesStore(application) private val appPreferencesStore = AppPreferencesStore(application)
private val repository = DriverRepository(application) private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository) private val syncRepository = DriverSyncRepository(application, repository)
private val startupOfflineFallbackLoader = StartupOfflineFallbackLoader()
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application) private val photoUploadOutbox = PhotoUploadOutbox(application)
private val routeActionOutbox = RouteActionOutbox(application) private val routeActionOutbox = RouteActionOutbox(application)
@@ -196,6 +207,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application) private val diagnosticSnapshotProvider = DiagnosticSnapshotProvider(application)
private val _state = MutableStateFlow(DriverUiState(loading = true)) private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val otpAttemptCoordinator = OtpAttemptCoordinator()
private val liveSyncClient = DriverLiveSyncClient( private val liveSyncClient = DriverLiveSyncClient(
repository = repository, repository = repository,
onConnected = { viewModelScope.launch { checkRemoteSyncState() } }, onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
@@ -215,6 +227,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private var leaveCalendarRequestGeneration: Long = 0 private var leaveCalendarRequestGeneration: Long = 0
private var routesRequestGeneration: Long = 0 private var routesRequestGeneration: Long = 0
private var routeDetailRequestGeneration: Long = 0 private var routeDetailRequestGeneration: Long = 0
private var routesLoadJob: Job? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
init { init {
@@ -295,6 +308,8 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
viewModelScope.launch { viewModelScope.launch {
if (repository.hasToken()) { if (repository.hasToken()) {
val offlineAvailable = syncRepository.hasBootstrapCache(_state.value.selectedDate)
_state.update { it.copy(startupOfflineAvailable = offlineAvailable) }
refreshRoutes() refreshRoutes()
} else { } else {
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) } _state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
@@ -302,19 +317,64 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun requestOtp(phone: String) = runLoading("request_otp") { fun requestOtp(phone: String) {
if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.") val requestSnapshot = _state.value
val response = repository.requestOtp(phone) val now = System.currentTimeMillis()
val resendDelay = if (requestSnapshot.phone == phone) {
otpResendDelaySeconds(requestSnapshot.otpResendAvailableAtEpochMillis, now)
} else {
0
}
if (resendDelay > 0) {
_state.update { it.copy(error = "Nowy SMS możesz wysłać za $resendDelay s.") }
return
}
val isResend = requestSnapshot.screen == DriverScreen.Otp && requestSnapshot.phone == phone
val retainedCode = requestSnapshot.otpCode.takeIf { isResend }.orEmpty()
val attemptId = otpAttemptCoordinator.beginAttempt()
otpAutoSubmitPolicy.reset() otpAutoSubmitPolicy.reset()
viewModelScope.launch {
_state.update {
it.copy(
loading = true,
phone = phone,
otpCode = retainedCode,
otpConsentRequestId = attemptId,
otpRequestPending = true,
error = null,
)
}
kotlinx.coroutines.yield()
val result = runCatching {
if (!_state.value.isOnline) throw IOException("Logowanie wymaga połączenia z internetem.")
repository.requestOtp(phone)
}
if (!otpAttemptCoordinator.isCurrent(attemptId)) return@launch
result.onSuccess { response ->
val bufferedCode = otpAttemptCoordinator.takeBufferedCode(attemptId)
_state.update { _state.update {
it.copy( it.copy(
screen = DriverScreen.Otp, screen = DriverScreen.Otp,
phone = phone, loading = false,
otpCode = "", otpCode = bufferedCode ?: retainedCode,
maskedPhone = response.phoneMasked.orEmpty(), maskedPhone = response.phoneMasked.orEmpty(),
otpRequestPending = false,
otpResendAvailableAtEpochMillis = System.currentTimeMillis() + OTP_RESEND_COOLDOWN_MILLIS,
error = null, error = null,
) )
} }
bufferedCode?.let { applyOtpCode(it, retrievedFromSms = true) }
}.onFailure { throwable ->
otpAttemptCoordinator.discard(attemptId)
reportHandledException("request_otp", throwable)
_state.update { it.withApiError(throwable).copy(loading = false, otpRequestPending = false) }
}
}
} }
fun verifyOtp(code: String) = runLoading("verify_otp") { fun verifyOtp(code: String) = runLoading("verify_otp") {
@@ -326,11 +386,35 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
fun updateOtpCode(code: String) { fun updateOtpCode(code: String) {
val sanitized = code.filter(Char::isDigit).take(6) applyOtpCode(sanitizeOtpCode(code), retrievedFromSms = false)
_state.update { it.copy(otpCode = sanitized) } }
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) { fun updateOtpCodeFromSms(attemptId: Long, code: String) {
verifyOtp(sanitized) val sanitized = sanitizeOtpCode(code)
if (!isRetrievedOtpCode(sanitized)) return
val snapshot = _state.value
if (attemptId != snapshot.otpConsentRequestId || !otpAttemptCoordinator.isCurrent(attemptId)) return
if (snapshot.otpRequestPending) {
otpAttemptCoordinator.bufferRetrievedCode(attemptId, sanitized)
return
}
if (snapshot.screen != DriverScreen.Otp) return
applyOtpCode(sanitized, retrievedFromSms = true)
}
private fun applyOtpCode(code: String, retrievedFromSms: Boolean) {
_state.update { it.copy(otpCode = code) }
val shouldSubmit = if (retrievedFromSms) {
otpAutoSubmitPolicy.shouldSubmitRetrieved(code, _state.value.loading)
} else {
otpAutoSubmitPolicy.shouldSubmit(code, _state.value.loading)
}
if (shouldSubmit) {
verifyOtp(code)
} }
} }
@@ -338,6 +422,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun useOfflineStartupDataNow() {
val snapshot = _state.value
if (snapshot.screen != DriverScreen.Initializing || !snapshot.startupOfflineAvailable) {
return
}
loadRoutes(
date = snapshot.selectedDate,
showLoading = true,
navigateToRoutes = true,
startupOfflineOnly = true,
)
}
fun onAppForegrounded() { fun onAppForegrounded() {
DriverRuntimeSyncState.foreground = true DriverRuntimeSyncState.foreground = true
liveSyncClient.setForeground(true) liveSyncClient.setForeground(true)
@@ -365,17 +463,31 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(routeDayLiveUpdateMessage = null) } _state.update { it.copy(routeDayLiveUpdateMessage = null) }
} }
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { private fun loadRoutes(
date: String?,
showLoading: Boolean,
navigateToRoutes: Boolean,
startupOfflineOnly: Boolean = false,
) {
val generation = ++routesRequestGeneration val generation = ++routesRequestGeneration
viewModelScope.launch { routesLoadJob?.cancel()
routesLoadJob = viewModelScope.launch {
val isStartup = _state.value.screen == DriverScreen.Initializing
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) } _state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching { runCatching {
val cached = if (_state.value.isOnline) { val cached = when {
syncRepository.bootstrap(date) startupOfflineOnly -> syncRepository.bootstrapFromCache(date)
} else {
syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.") ?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
!_state.value.isOnline -> syncRepository.bootstrapFromCache(date)
?: throw IOException("Brak zapisanych danych dla wybranego dnia.")
isStartup -> {
startupOfflineFallbackLoader.load(
onlineLoad = { syncRepository.bootstrap(date) },
offlineLoad = { syncRepository.bootstrapFromCache(date) },
)
}
else -> syncRepository.bootstrap(date)
} }
val response = cached.value val response = cached.value
val settings = response.driverAppSettings val settings = response.driverAppSettings
@@ -386,6 +498,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
driver = response.session.driver, driver = response.session.driver,
routes = response.routes.today, routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder, dispatchSheetReminder = response.dispatchSheetReminder,
appUpdate = response.appUpdate ?: it.appUpdate,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate, selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate, minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate, maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
@@ -424,17 +537,16 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}.onFailure { throwable -> }.onFailure { throwable ->
if (generation != routesRequestGeneration) return@onFailure if (generation != routesRequestGeneration) return@onFailure
reportHandledException("load_routes", throwable, mapOf("date" to date)) reportHandledException("load_routes", throwable, mapOf("date" to date))
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
handleExpiredSession()
} else {
_state.update { _state.update {
val apiError = ApiErrorMapper.map(throwable)
it.withApiError(throwable).copy( it.withApiError(throwable).copy(
screen = when { screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Routes else it.screen,
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone
it.screen == DriverScreen.Initializing -> DriverScreen.Routes
else -> it.screen
},
) )
} }
} }
}
if (generation == routesRequestGeneration) _state.update { if (generation == routesRequestGeneration) _state.update {
it.copy(loading = false, refreshing = false) it.copy(loading = false, refreshing = false)
@@ -464,6 +576,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,
@@ -495,6 +612,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
photoUploadOutbox.discardConfirmedServerPhotos(response.route) photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update { _state.update {
it.copy( it.copy(
screen = routeScreenAfterServerRefresh(it.screen, response.route),
selectedRoute = response.route, selectedRoute = response.route,
routes = it.routes.map { route -> if (route.id == routeId) response.route else route }, routes = it.routes.map { route -> if (route.id == routeId) response.route else route },
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
@@ -508,7 +626,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
.onFailure { throwable -> .onFailure { throwable ->
if (generation == routeDetailRequestGeneration) _state.update { it.withApiError(throwable) } if (generation != routeDetailRequestGeneration) return@onFailure
if (ApiErrorMapper.map(throwable).kind == ApiErrorKind.Auth) {
handleExpiredSession()
} else {
_state.update { it.withApiError(throwable) }
}
} }
if (generation == routeDetailRequestGeneration) _state.update { it.copy(refreshing = false) } if (generation == routeDetailRequestGeneration) _state.update { it.copy(refreshing = false) }
@@ -639,6 +762,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun markNotificationPermissionBlocked() {
_state.update {
it.copy(
notificationPermissionDenied = true,
error = "Powiadomienia są włączone w aplikacji, ale Android blokuje ich wyświetlanie. Włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun clearNotificationPermissionWarning() {
_state.update {
it.copy(
notificationPermissionDenied = false,
error = if (it.error?.contains("powiadom", ignoreCase = true) == true) null else it.error,
)
}
}
fun setThemeMode(themeMode: AppThemeMode) { fun setThemeMode(themeMode: AppThemeMode) {
_state.update { it.copy(themeMode = themeMode) } _state.update { it.copy(themeMode = themeMode) }
viewModelScope.launch { viewModelScope.launch {
@@ -978,6 +1119,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,
) )
@@ -988,7 +1130,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value val snapshot = _state.value
val route = snapshot.selectedRoute ?: return val route = snapshot.selectedRoute ?: return
val stagePhotos = routePhotosForStage(route.photos, "loading") val stagePhotos = routePhotosForStage(route.photos, "loading")
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" } val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == "loading" },
)
val blocker = loadingPhotosSubmitBlocker( val blocker = loadingPhotosSubmitBlocker(
serverPhotoCount = stagePhotos.size, serverPhotoCount = stagePhotos.size,
localUploadCount = stageUploads.size, localUploadCount = stageUploads.size,
@@ -1020,6 +1164,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,
) )
@@ -1031,6 +1176,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)
@@ -1040,17 +1189,24 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
?: return ?: return
viewModelScope.launch { viewModelScope.launch {
runCatching { routeActionOutbox.discard(action.clientActionId) } val interrupted = isWorkerCancellationFailure(action.lastError)
runCatching {
if (interrupted) routeActionOutbox.retry(action.clientActionId)
else routeActionOutbox.discard(action.clientActionId)
}
.onSuccess { .onSuccess {
_state.update { _state.update {
it.copy( it.copy(
routeActions = it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId }, routeActions = if (interrupted) it.routeActions else it.routeActions.filterNot { item -> item.clientActionId == action.clientActionId },
visibleRouteActions = it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId }, visibleRouteActions = if (interrupted) it.visibleRouteActions else it.visibleRouteActions.filterNot { item -> item.clientActionId == action.clientActionId },
feedback = if (interrupted) "Ponawiam potwierdzenie z tym samym identyfikatorem operacji." else it.feedback,
error = null, error = null,
) )
} }
if (!interrupted) {
if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute() if (action.action == RouteActionType.Start) openStartRoute() else openFinishRoute()
} }
}
.onFailure { throwable -> _state.update { it.withApiError(throwable) } } .onFailure { throwable -> _state.update { it.withApiError(throwable) } }
} }
} }
@@ -1085,12 +1241,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement val photoRequirement = if (stage == "loading") snapshot.loadingPhotoRequirement else snapshot.unloadingPhotoRequirement
val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement val weightRequirement = if (stage == "loading") snapshot.loadingWeightRequirement else snapshot.unloadingWeightRequirement
val weight = if (routeStageRequirementIsVisible(weightRequirement)) { val weight = if (routeStageRequirementIsVisible(weightRequirement)) {
snapshot.routeStageWeightText.trim().replace(',', '.').toDoubleOrNull() parseRouteWeightTons(snapshot.routeStageWeightText)
} else { } else {
null null
} }
val notes = normalizeRouteStageNotes(snapshot.routeStageNotesText).trim().takeIf { it.isNotBlank() }
val stagePhotos = routePhotosForStage(route.photos, stage) val stagePhotos = routePhotosForStage(route.photos, stage)
val stageUploads = snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage } val stageUploads = routeStageUsableUploads(
snapshot.photoUploads.filter { normalizedRoutePhotoStage(it.stage) == stage },
)
val submitBlocker = if (stage == "loading") { val submitBlocker = if (stage == "loading") {
loadingWeightSubmitBlocker( loadingWeightSubmitBlocker(
weightText = snapshot.routeStageWeightText, weightText = snapshot.routeStageWeightText,
@@ -1133,19 +1292,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",
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",
unloadingWeight = weight, unloadingWeight = weight,
unloadingNotes = notes,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = Instant.now().toString(), completedAt = Instant.now().toString(),
photos = route.safePhotos(), photos = route.safePhotos(),
@@ -1160,6 +1323,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 {
@@ -1244,13 +1408,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun deleteConfirmedUpload(upload: PhotoUploadEntity) { fun deletePhotoUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId)
if (serverPhotoId == null) {
if (!canDiscardLocalPhotoUpload(upload.status, upload.serverPhotoId)) return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + upload.clientRequestId, error = null) }
runCatching { photoUploadOutbox.discard(upload.clientRequestId) }
.onFailure { throwable -> _state.update { it.withApiError(throwable) } }
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - upload.clientRequestId) }
}
return
}
if (!_state.value.isOnline) { if (!_state.value.isOnline) {
_state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") } _state.update { it.copy(error = "Usunięcie wysłanego zdjęcia wymaga połączenia z internetem.") }
return return
} }
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) } _state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
@@ -1328,9 +1502,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)
@@ -1374,6 +1558,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
dispatchSheetUploadsJob?.cancel() dispatchSheetUploadsJob?.cancel()
realtimeBannerJob?.cancel() realtimeBannerJob?.cancel()
liveSyncClient.stop() liveSyncClient.stop()
ActiveRouteTrackingService.stop(getApplication())
offlineOutboxManager.clearAll() offlineOutboxManager.clearAll()
repository.logout(notifyServer = _state.value.isOnline) repository.logout(notifyServer = _state.value.isOnline)
syncRepository.clearCache() syncRepository.clearCache()
@@ -1412,7 +1597,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) { private fun reconcileTrackingService(routes: List<DriverRouteDto>, driver: DriverDto) {
val activeRoute = routes.firstOrNull { it.status == "W TRAKCIE" || it.trackingStatus == "active" } val activeRoute = routes.firstOrNull { it.driverLifecycleStatus() == "W TRAKCIE" }
if (activeRoute == null) { if (activeRoute == null) {
ActiveRouteTrackingService.stop(getApplication()) ActiveRouteTrackingService.stop(getApplication())
} else { } else {
@@ -1426,7 +1611,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) { private fun reconcileTrackingServiceForRoute(route: DriverRouteDto, driver: DriverDto?) {
if (route.status == "W TRAKCIE" || route.trackingStatus == "active") { if (route.driverLifecycleStatus() == "W TRAKCIE") {
if (driver != null) { if (driver != null) {
ActiveRouteTrackingService.start( ActiveRouteTrackingService.start(
context = getApplication(), context = getApplication(),
@@ -1584,6 +1769,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private suspend fun handleExpiredSession() { private suspend fun handleExpiredSession() {
liveSyncClient.stop() liveSyncClient.stop()
ActiveRouteTrackingService.stop(getApplication())
offlineOutboxManager.clearAll() offlineOutboxManager.clearAll()
repository.logout(notifyServer = false) repository.logout(notifyServer = false)
syncRepository.clearCache() syncRepository.clearCache()
@@ -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,45 @@
package pl.firmatpp.kierowca.ui
class OtpAttemptCoordinator {
private var currentAttemptId: Long = 0L
private var bufferedCode: String? = null
fun beginAttempt(): Long {
currentAttemptId += 1L
bufferedCode = null
return currentAttemptId
}
fun isCurrent(attemptId: Long): Boolean = attemptId == currentAttemptId
fun bufferRetrievedCode(attemptId: Long, code: String): Boolean {
if (!isCurrent(attemptId) || !isRetrievedOtpCode(code)) return false
bufferedCode = code
return true
}
fun takeBufferedCode(attemptId: Long): String? {
if (!isCurrent(attemptId)) return null
return bufferedCode.also { bufferedCode = null }
}
fun discard(attemptId: Long) {
if (isCurrent(attemptId)) bufferedCode = null
}
}
internal fun sanitizeOtpCode(code: String): String =
code.filter(Char::isDigit).take(OTP_MAX_LENGTH)
internal fun isRetrievedOtpCode(code: String): Boolean =
code.length in OTP_MIN_LENGTH..OTP_MAX_LENGTH && code.all(Char::isDigit)
internal fun otpResendDelaySeconds(availableAtEpochMillis: Long, nowEpochMillis: Long): Int {
val remainingMillis = (availableAtEpochMillis - nowEpochMillis).coerceAtLeast(0L)
return ((remainingMillis + 999L) / 1_000L).toInt()
}
internal const val OTP_MIN_LENGTH = 4
internal const val OTP_DEFAULT_LENGTH = 6
internal const val OTP_MAX_LENGTH = 10
internal const val OTP_RESEND_COOLDOWN_MILLIS = 30_000L
@@ -4,14 +4,20 @@ class OtpAutoSubmitPolicy {
private var lastSubmittedCode: String? = null private var lastSubmittedCode: String? = null
fun shouldSubmit(code: String, loading: Boolean): Boolean { fun shouldSubmit(code: String, loading: Boolean): Boolean {
if (code.length < OTP_LENGTH) { return shouldSubmitCompleteCode(code, loading, code.length == OTP_DEFAULT_LENGTH)
}
fun shouldSubmitRetrieved(code: String, loading: Boolean): Boolean {
return shouldSubmitCompleteCode(code, loading, isRetrievedOtpCode(code))
}
private fun shouldSubmitCompleteCode(code: String, loading: Boolean, complete: Boolean): Boolean {
if (!complete) {
lastSubmittedCode = null lastSubmittedCode = null
return false return false
} }
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) { if (loading || code == lastSubmittedCode) return false
return false
}
lastSubmittedCode = code lastSubmittedCode = code
return true return true
@@ -20,8 +26,4 @@ class OtpAutoSubmitPolicy {
fun reset() { fun reset() {
lastSubmittedCode = null lastSubmittedCode = null
} }
private companion object {
const val OTP_LENGTH = 6
}
} }
@@ -0,0 +1,146 @@
package pl.firmatpp.kierowca.update
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import com.google.android.play.core.appupdate.AppUpdateInfo
import com.google.android.play.core.appupdate.AppUpdateManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.InstallStateUpdatedListener
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
data class PlayAppUpdateState(
val updateAvailable: Boolean = false,
val availableVersionCode: Int? = null,
val downloaded: Boolean = false,
val checking: Boolean = false,
)
class PlayInAppUpdateController(
private val activity: ComponentActivity,
private val launcher: ActivityResultLauncher<IntentSenderRequest>,
private val onStateChanged: (PlayAppUpdateState) -> Unit,
private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity),
) {
private var state = PlayAppUpdateState()
private val installStateListener = InstallStateUpdatedListener { installState ->
when (installState.installStatus()) {
InstallStatus.DOWNLOADED -> updateState(state.copy(downloaded = true, updateAvailable = true))
InstallStatus.INSTALLED -> updateState(PlayAppUpdateState())
else -> Unit
}
}
init {
appUpdateManager.registerListener(installStateListener)
}
fun checkForUpdate() {
updateState(state.copy(checking = true))
appUpdateManager.appUpdateInfo
.addOnSuccessListener(::handleUpdateInfo)
.addOnFailureListener { updateState(state.copy(checking = false)) }
}
fun startUpdate(required: Boolean, playStoreUrl: String?) {
appUpdateManager.appUpdateInfo
.addOnSuccessListener { info ->
if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE) {
openPlayStore(playStoreUrl)
return@addOnSuccessListener
}
val preferredType = if (required) AppUpdateType.IMMEDIATE else AppUpdateType.FLEXIBLE
val updateType = when {
info.isUpdateTypeAllowed(preferredType) -> preferredType
info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> AppUpdateType.FLEXIBLE
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> AppUpdateType.IMMEDIATE
else -> null
}
if (updateType == null || !launchUpdate(info, updateType)) {
openPlayStore(playStoreUrl)
}
}
.addOnFailureListener { openPlayStore(playStoreUrl) }
}
fun completeDownloadedUpdate() {
appUpdateManager.completeUpdate()
}
fun onUpdateFlowResult(resultCode: Int) {
if (resultCode != Activity.RESULT_OK) {
checkForUpdate()
}
}
fun dispose() {
appUpdateManager.unregisterListener(installStateListener)
}
private fun handleUpdateInfo(info: AppUpdateInfo) {
val downloaded = info.installStatus() == InstallStatus.DOWNLOADED
val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE ||
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS ||
downloaded
updateState(
PlayAppUpdateState(
updateAvailable = available,
availableVersionCode = info.availableVersionCode().takeIf { it > 0 },
downloaded = downloaded,
checking = false,
),
)
if (
info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS &&
info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
) {
launchUpdate(info, AppUpdateType.IMMEDIATE)
}
}
private fun launchUpdate(info: AppUpdateInfo, updateType: Int): Boolean =
appUpdateManager.startUpdateFlowForResult(
info,
launcher,
AppUpdateOptions.newBuilder(updateType).build(),
)
private fun openPlayStore(playStoreUrl: String?) {
val packageName = activity.packageName
val candidates = listOf(
Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).setPackage("com.android.vending"),
Intent(
Intent.ACTION_VIEW,
Uri.parse(playStoreUrl?.takeIf { it.isNotBlank() }
?: "https://play.google.com/store/apps/details?id=$packageName"),
),
)
for (intent in candidates) {
try {
activity.startActivity(intent)
return
} catch (_: ActivityNotFoundException) {
// Try the browser fallback.
} catch (_: SecurityException) {
// Try the browser fallback.
}
}
}
private fun updateState(next: PlayAppUpdateState) {
state = next
onStateChanged(next)
}
}
@@ -2,12 +2,31 @@ package pl.firmatpp.kierowca.data
import java.io.IOException import java.io.IOException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.coroutines.cancellation.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class ApiErrorMapperTest { class ApiErrorMapperTest {
@Test
fun rethrowsWorkerCancellationInsteadOfMappingItAsApplicationFailure() {
val cancellation = CancellationException("Job was cancelled")
try {
cancellation.rethrowIfCancellation()
fail("CancellationException should be rethrown")
} catch (thrown: CancellationException) {
assertSame(cancellation, thrown)
}
}
@Test @Test
fun mapsNetworkFailuresToRetryableOfflineMessage() { fun mapsNetworkFailuresToRetryableOfflineMessage() {
val error = ApiErrorMapper.map(IOException("timeout")) val error = ApiErrorMapper.map(IOException("timeout"))
@@ -69,4 +88,30 @@ class ApiErrorMapperTest {
assertEquals("Storage timeout", error.message) assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody) assertEquals(body, error.responseBody)
} }
@Test
fun mapsInvalidOtpReasonToActionableMessage() {
val error = ApiErrorMapper.mapHttpStatus(422, """{"ok":false,"reason":"INVALID_OTP"}""")
assertEquals("INVALID_OTP", error.code)
assertEquals("Kod jest nieprawidłowy lub wygasł. Użyj kodu z najnowszego SMS-a.", error.message)
}
@Test
fun translatesRateLimitResponses() {
val error = ApiErrorMapper.mapHttpStatus(429, """{"message":"Too Many Attempts."}""")
assertEquals(ApiErrorKind.RateLimited, error.kind)
assertEquals("Za dużo prób. Odczekaj chwilę i spróbuj ponownie.", error.message)
}
@Test
fun preservesApiProblemWhenSameExceptionIsMappedMoreThanOnce() {
val body = """{"ok":false,"reason":"INVALID_OTP"}"""
.toResponseBody("application/json".toMediaType())
val exception = HttpException(Response.error<Any>(422, body))
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
assertEquals("INVALID_OTP", ApiErrorMapper.map(exception).code)
}
} }
@@ -0,0 +1,70 @@
package pl.firmatpp.kierowca.data.sync
import java.io.IOException
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class StartupOfflineFallbackLoaderTest {
@Test
fun returnsServerDataBeforeTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = { "server" },
offlineLoad = { "cache" },
)
assertEquals("server", result)
}
@Test
fun returnsCachedDataWhenServerExceedsTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val result = loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { "cache" },
)
assertEquals("cache", result)
}
@Test
fun reportsMissingOfflineDataAfterTimeout() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val error = runCatching {
loader.load(
onlineLoad = {
delay(2_000L)
"server"
},
offlineLoad = { null },
)
}.exceptionOrNull()
assertTrue(error is IOException)
}
@Test
fun doesNotHideAuthenticationFailures() = runTest {
val loader = StartupOfflineFallbackLoader(timeoutMillis = 1_000L)
val authenticationFailure = IllegalStateException("401")
val error = runCatching {
loader.load(
onlineLoad = { throw authenticationFailure },
offlineLoad = { "cache" },
)
}.exceptionOrNull()
assertTrue(error is IllegalStateException)
assertEquals(authenticationFailure.message, error?.message)
}
}
@@ -0,0 +1,43 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OfflineOutboxManagerTest {
@Test
fun recognizesLegacyCancellationFailureDetails() {
assertTrue(isWorkerCancellationFailure("Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertTrue(isWorkerCancellationFailure("kotlinx.coroutines.JobCancellationException"))
assertFalse(isWorkerCancellationFailure("HTTP 422: Niepoprawne zdjęcie"))
assertFalse(isWorkerCancellationFailure(null))
}
@Test
fun repairsInterruptedAndRetryablePhotoUploadsButNotRealPermanentFailures() {
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Pending.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Uploading.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.Verifying.storageValue, null))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedRetryable.storageValue, "timeout"))
assertTrue(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "Job was cancelled"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.FailedPermanent.storageValue, "HTTP 422"))
assertFalse(shouldRepairPhotoUpload(PhotoUploadStatus.Cancelled.storageValue, null))
}
@Test
fun repairsInterruptedRouteActionWithItsOriginalIdempotencyKey() {
assertTrue(shouldRepairRouteAction(RouteActionStatus.Pending, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.WaitingForPhotos, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.Syncing, null))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedRetryable, "timeout"))
assertTrue(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Job was cancelled"))
assertTrue(
shouldRepairRouteAction(
RouteActionStatus.FailedPermanent,
"Wymagane zdjęcie nie zostało wysłane. Popraw zdjęcie i ponów potwierdzenie etapu.",
),
)
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedPermanent, "Brakuje zdjęcia"))
assertFalse(shouldRepairRouteAction(RouteActionStatus.FailedConflict, "Kurs już rozpoczęty"))
}
}
@@ -0,0 +1,62 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
class PhotoUploadReconciliationTest {
@Test
fun serverReceiptIdentityClearsFalseLocalFailure() {
val upload = upload(
clientRequestId = "request-1",
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", upload.contentSha256.uppercase())))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-2", upload.contentSha256)))
assertFalse(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")))
}
@Test
fun legacyServerPhotoWithoutHashStillConfirmsMatchingRequestId() {
val upload = upload("request-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
assertTrue(serverPhotoConfirmsUpload(upload, serverPhoto("request-1", null)))
}
private fun upload(clientRequestId: String, contentSha256: String): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = clientRequestId,
routeId = "10",
localPath = "/tmp/$clientRequestId.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 100,
contentSha256 = contentSha256,
status = PhotoUploadStatus.FailedPermanent.storageValue,
lastError = "Typ: błąd aplikacji\nKomunikat: Job was cancelled",
)
private fun serverPhoto(clientRequestId: String, contentSha256: String?): RoutePhotoDto =
RoutePhotoDto(
id = "42",
routeId = "10",
clientRequestId = clientRequestId,
contentSha256 = contentSha256,
source = "camera",
stage = "loading",
mimeType = "image/jpeg",
size = 100,
url = "https://example.test/photo/42",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
createdAt = null,
)
}
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.ApiErrorKind
class RoutePointWorkerRulesTest { class RoutePointWorkerRulesTest {
@Test @Test
@@ -15,4 +16,14 @@ class RoutePointWorkerRulesTest {
assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE")) assertFalse(isInactiveRoutePointError("SERVER_UNAVAILABLE"))
assertFalse(isInactiveRoutePointError(null)) assertFalse(isInactiveRoutePointError(null))
} }
@Test
fun revokedSessionStopsRouteTracking() {
assertTrue(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Auth, null))
}
@Test
fun temporaryFailureKeepsRouteTrackingActive() {
assertFalse(shouldStopRouteTrackingAfterPointError(ApiErrorKind.Server, "SERVER_UNAVAILABLE"))
}
} }
@@ -16,7 +16,9 @@ class RouteDisplayMapperTest {
contractName = "Odpady przemyslowe", contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24", contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask", relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA", status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
distanceKm = 91.4, distanceKm = 91.4,
notes = null, notes = null,
truck = null, truck = null,
@@ -29,6 +31,7 @@ class RouteDisplayMapperTest {
assertEquals("Instalacja Odpadowa Slask", display.destination) assertEquals("Instalacja Odpadowa Slask", display.destination)
assertEquals("TPP-ODP-24", display.contractLabel) assertEquals("TPP-ODP-24", display.contractLabel)
assertEquals("09:15", display.time) assertEquals("09:15", display.time)
assertEquals("ZAPLANOWANA", display.status)
assertEquals("91 km", display.distanceLabel) assertEquals("91 km", display.distanceLabel)
} }
} }
@@ -0,0 +1,32 @@
package pl.firmatpp.kierowca.domain
import org.junit.Assert.assertEquals
import org.junit.Test
class StartupSessionPolicyTest {
@Test
fun rotatesStartupMessagesAsWaitingTimeIncreases() {
assertEquals("Sprawdzamy zapisaną sesję", StartupSessionPolicy.message(0).title)
assertEquals("Potwierdzamy logowanie", StartupSessionPolicy.message(3).title)
assertEquals("Pobieramy aktualne kursy", StartupSessionPolicy.message(6).title)
assertEquals("To trwa dłużej niż zwykle", StartupSessionPolicy.message(9).title)
assertEquals("Przygotowujemy tryb offline", StartupSessionPolicy.message(12).title)
}
@Test
fun countsDownToOfflineFallbackWithoutGoingBelowZero() {
assertEquals(15, StartupSessionPolicy.remainingSeconds(0))
assertEquals(7, StartupSessionPolicy.remainingSeconds(8))
assertEquals(0, StartupSessionPolicy.remainingSeconds(15))
assertEquals(0, StartupSessionPolicy.remainingSeconds(30))
assertEquals("Tryb offline najpóźniej za 5 s", StartupSessionPolicy.countdownLabel(10))
assertEquals("Otwieramy zapisane dane offline", StartupSessionPolicy.countdownLabel(15))
}
@Test
fun showsManualOfflineButtonAfterFourSecondsOnlyWhenCacheExists() {
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(3, offlineDataAvailable = true))
assertEquals(true, StartupSessionPolicy.shouldShowOfflineButton(4, offlineDataAvailable = true))
assertEquals(false, StartupSessionPolicy.shouldShowOfflineButton(10, offlineDataAvailable = false))
}
}
@@ -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)
@@ -174,6 +191,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 +204,7 @@ class DriverRouteProjectionTest {
photoClientRequestIdsJson = "[]", photoClientRequestIdsJson = "[]",
status = status, status = status,
createdAtEpochMillis = createdAt, createdAtEpochMillis = createdAt,
notes = notes,
) )
private companion object { private companion object {
@@ -82,6 +82,108 @@ class DriverUiRulesTest {
assertEquals("", routeDateChipLabel("", today)) assertEquals("", routeDateChipLabel("", today))
} }
@Test
fun buildsNavigationCandidatesFromGeofenceCoordinatesWithFallbacks() {
val candidates = navigationIntentCandidates(
latitude = 50.0619474,
longitude = 19.9368564,
label = "Brama główna",
)
assertEquals(3, candidates.size)
assertEquals("com.google.android.apps.maps", candidates[0].packageName)
assertEquals("google.navigation:q=50.0619474,19.9368564", candidates[0].uri)
assertEquals(null, candidates[1].packageName)
assertEquals(
"geo:0,0?q=50.0619474,19.9368564(Brama%20g%C5%82%C3%B3wna)",
candidates[1].uri,
)
assertEquals(
"https://www.google.com/maps/dir/?api=1&destination=50.0619474,19.9368564",
candidates[2].uri,
)
}
@Test
fun doesNotBuildNavigationCandidatesForMissingOrInvalidCoordinates() {
assertFalse(hasValidNavigationCoordinates(null, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, null))
assertFalse(hasValidNavigationCoordinates(Double.NaN, 19.9368564))
assertFalse(hasValidNavigationCoordinates(91.0, 19.9368564))
assertFalse(hasValidNavigationCoordinates(50.0619474, 181.0))
assertTrue(hasValidNavigationCoordinates(50.0619474, 19.9368564))
assertTrue(navigationIntentCandidates(null, null, "Cel").isEmpty())
}
@Test
fun showsAnOptionalUpdateWhenGooglePlayOrBackendHasANewerVersion() {
val fromPlay = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = null,
latestVersionName = null,
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val fromBackend = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(false, fromPlay?.required)
assertEquals("Aktualizuj", fromPlay?.actionLabel)
assertEquals("Dostępna jest nowa wersja aplikacji 1.0.61.", fromBackend?.message)
}
@Test
fun requiresAnUpdateOnlyBelowTheConfiguredMinimumVersion() {
val required = appUpdateBannerUi(
currentVersionCode = 111,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = false,
)
val current = appUpdateBannerUi(
currentVersionCode = 114,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 112,
playUpdateAvailable = false,
playAvailableVersionCode = null,
updateDownloaded = false,
)
assertEquals(true, required?.required)
assertTrue(required?.message?.contains("nie jest już obsługiwana") == true)
assertEquals(null, current)
}
@Test
fun downloadedUpdateUsesInstallAction() {
val update = appUpdateBannerUi(
currentVersionCode = 113,
latestVersionCode = 114,
latestVersionName = "1.0.61",
minimumSupportedVersionCode = 0,
playUpdateAvailable = true,
playAvailableVersionCode = 114,
updateDownloaded = true,
)
assertEquals("Zainstaluj", update?.actionLabel)
assertEquals(true, update?.downloaded)
assertTrue(update?.message?.contains("gotowa do instalacji") == true)
}
@Test @Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0)) assertEquals(0, inlinePhotoGridRows(0))
@@ -205,8 +307,9 @@ class DriverUiRulesTest {
} }
@Test @Test
fun allowsRetryActionOnlyForRetryableFailedUploads() { fun allowsRetryForRetryableAndLegacyCancelledUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE")) assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertTrue(canRetryPhotoUpload("FAILED_PERMANENT", "Typ: błąd aplikacji\nKomunikat: Job was cancelled"))
assertFalse(canRetryPhotoUpload("PENDING")) assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING")) assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING")) assertFalse(canRetryPhotoUpload("VERIFYING"))
@@ -215,6 +318,37 @@ class DriverUiRulesTest {
assertFalse(canRetryPhotoUpload("CANCELLED")) assertFalse(canRetryPhotoUpload("CANCELLED"))
} }
@Test
fun permanentUploadDoesNotSatisfyRouteStageAndCanBeDiscarded() {
val failed = upload(status = "FAILED_PERMANENT")
val pending = upload(status = "PENDING").copy(clientRequestId = "pending-upload")
assertEquals(listOf("pending-upload"), routeStageUsableUploads(listOf(failed, pending)).map { it.clientRequestId })
assertFalse(photoUploadCanSatisfyStage("FAILED_PERMANENT"))
assertTrue(canDiscardLocalPhotoUpload("FAILED_PERMANENT", null))
assertFalse(canDiscardLocalPhotoUpload("CONFIRMED", "42"))
}
@Test
fun serverRefreshLeavesObsoleteRouteStageScreen() {
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "W TRAKCIE")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.LoadingWeight, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.Detail,
routeScreenAfterServerRefresh(DriverScreen.FinishRoute, route(status = "ZAKOŃCZONA")),
)
assertEquals(
DriverScreen.StartRoute,
routeScreenAfterServerRefresh(DriverScreen.StartRoute, route(status = "ZAPLANOWANA")),
)
}
@Test @Test
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() { fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera")) assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
@@ -306,11 +440,20 @@ class DriverUiRulesTest {
fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() { fun routeStageFormRequiresPositiveWeightAndAtLeastOnePhotoOrQueuedUpload() {
assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0)) assertTrue(canSubmitRouteStageForm(weightText = "12,5", serverPhotoCount = 1, localUploadCount = 0))
assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1)) assertTrue(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 1))
assertTrue(canSubmitRouteStageForm(weightText = "23400", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "0", serverPhotoCount = 1, localUploadCount = 0))
assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0)) assertFalse(canSubmitRouteStageForm(weightText = "12.5", serverPhotoCount = 0, localUploadCount = 0))
} }
@Test
fun parsesRouteWeightsEnteredInTonsOrKilograms() {
assertEquals(23.4, parseRouteWeightTons("23,4") ?: 0.0, 0.0)
assertEquals(23.4, parseRouteWeightTons("23400") ?: 0.0, 0.0)
assertEquals(23.401, parseRouteWeightTons("23400,5") ?: 0.0, 0.0)
assertEquals("Wpisano kilogramy — zapiszemy 23,400 t.", routeWeightInputHelpText("23400"))
}
@Test @Test
fun filtersRoutePhotosByStageWithOtherFallback() { fun filtersRoutePhotosByStageWithOtherFallback() {
val photos = listOf( val photos = listOf(
@@ -356,18 +499,62 @@ class DriverUiRulesTest {
assertEquals(RouteLifecycleAction.Start, action?.action) assertEquals(RouteLifecycleAction.Start, action?.action)
} }
@Test
fun driverFlowIgnoresOperationalStatusFromGeofence() {
val route = route(
status = "ZAKOŃCZONA",
driverStatus = "ZAPLANOWANA",
operationalStatus = "ZAKOŃCZONA",
)
val steps = routeFlowSteps(route, emptyList())
assertTrue(canStartRouteFromDriverApp(route, "2026-06-30", today))
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(DriverScreen.StartRoute, routeScreenAfterServerRefresh(DriverScreen.StartRoute, route))
}
@Test @Test
fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() { fun plannedRouteWithLoadingPhotoShowsOnlyPhotoStepComplete() {
val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading"))) val route = route(status = "ZAPLANOWANA", photos = listOf(photo("loading-1", stage = "loading")))
val steps = routeFlowSteps(route, emptyList()) val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.LocalComplete, steps[0].state) assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[1].state) assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state) assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state) assertEquals(RouteFlowStepState.Todo, steps[3].state)
} }
@Test
fun freshInstallLabelsBackendLoadingDocumentationAsServerData() {
val route = route(
status = "ZAPLANOWANA",
loadingWeight = 23.76,
photos = listOf(photo("loading-1", stage = "loading")),
)
val steps = routeFlowSteps(route, emptyList())
assertEquals(RouteFlowStepState.ServerSaved, steps[0].state)
assertEquals("zapisane na serwerze", steps[0].stateLabel)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].state)
assertEquals("zapisane na serwerze", steps[1].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
assertEquals(RouteFlowStepState.Todo, steps[3].state)
}
@Test
fun serverWeightDoesNotMarkMissingLoadingPhotoAsComplete() {
val steps = routeFlowSteps(route(status = "ZAPLANOWANA", loadingWeight = 23.76), emptyList())
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.ServerSaved, steps[1].state)
}
@Test @Test
fun activeRouteShowsTransitStateAndFinishAction() { fun activeRouteShowsTransitStateAndFinishAction() {
val route = route(status = "W TRAKCIE", loadingWeight = 12.5) val route = route(status = "W TRAKCIE", loadingWeight = 12.5)
@@ -377,7 +564,8 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.Confirmed, steps[0].state) assertEquals(RouteFlowStepState.Confirmed, steps[0].state)
assertEquals(RouteFlowStepState.Confirmed, steps[1].state) assertEquals(RouteFlowStepState.Confirmed, steps[1].state)
assertEquals(RouteFlowStepState.LocalComplete, steps[2].state) assertEquals(RouteFlowStepState.InProgress, steps[2].state)
assertEquals("w trakcie", steps[2].stateLabel)
assertEquals(RouteFlowStepState.Todo, steps[3].state) assertEquals(RouteFlowStepState.Todo, steps[3].state)
assertEquals("Rozładuj", action?.label) assertEquals("Rozładuj", action?.label)
assertEquals(RouteLifecycleAction.Finish, action?.action) assertEquals(RouteLifecycleAction.Finish, action?.action)
@@ -518,6 +706,8 @@ class DriverUiRulesTest {
private fun route( private fun route(
status: String = "ZAPLANOWANA", status: String = "ZAPLANOWANA",
driverStatus: String? = null,
operationalStatus: String? = null,
loadingWeight: Double? = null, loadingWeight: Double? = null,
unloadingWeight: Double? = null, unloadingWeight: Double? = null,
photos: List<RoutePhotoDto> = emptyList(), photos: List<RoutePhotoDto> = emptyList(),
@@ -536,6 +726,8 @@ class DriverUiRulesTest {
contractCode = "TPP-1", contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja", relationLabel = "Baza -> Instalacja",
status = status, status = status,
driverStatus = driverStatus,
operationalStatus = operationalStatus,
loadingWeight = loadingWeight, loadingWeight = loadingWeight,
unloadingWeight = unloadingWeight, unloadingWeight = unloadingWeight,
distanceKm = 1.0, distanceKm = 1.0,
@@ -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,
)
}
}
@@ -0,0 +1,48 @@
package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class OtpAttemptCoordinatorTest {
@Test
fun buffersSmsReceivedBeforeOtpRequestCompletes() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertTrue(coordinator.bufferRetrievedCode(attemptId, "123456"))
assertEquals("123456", coordinator.takeBufferedCode(attemptId))
assertNull(coordinator.takeBufferedCode(attemptId))
}
@Test
fun ignoresConsentResultFromPreviousRequest() {
val coordinator = OtpAttemptCoordinator()
val previousAttempt = coordinator.beginAttempt()
val currentAttempt = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(previousAttempt, "111111"))
assertTrue(coordinator.bufferRetrievedCode(currentAttempt, "222222"))
assertEquals("222222", coordinator.takeBufferedCode(currentAttempt))
}
@Test
fun acceptsOnlyOtpLengthsSupportedByApi() {
val coordinator = OtpAttemptCoordinator()
val attemptId = coordinator.beginAttempt()
assertFalse(coordinator.bufferRetrievedCode(attemptId, "123"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234"))
assertTrue(coordinator.bufferRetrievedCode(attemptId, "1234567890"))
assertFalse(coordinator.bufferRetrievedCode(attemptId, "12345678901"))
}
@Test
fun roundsResendCooldownUpToFullSeconds() {
assertEquals(30, otpResendDelaySeconds(40_000L, 10_000L))
assertEquals(1, otpResendDelaySeconds(10_001L, 10_000L))
assertEquals(0, otpResendDelaySeconds(10_000L, 10_001L))
}
}
@@ -38,4 +38,13 @@ class OtpAutoSubmitPolicyTest {
assertFalse(policy.shouldSubmit("123456", loading = true)) assertFalse(policy.shouldSubmit("123456", loading = true))
assertFalse(policy.shouldSubmit("1234567", loading = false)) assertFalse(policy.shouldSubmit("1234567", loading = false))
} }
@Test
fun submitsCompleteRetrievedCodesAcceptedByApi() {
val policy = OtpAutoSubmitPolicy()
assertTrue(policy.shouldSubmitRetrieved("1234", loading = false))
assertTrue(policy.shouldSubmitRetrieved("1234567890", loading = false))
assertFalse(policy.shouldSubmitRetrieved("123", loading = false))
}
} }
+3
View File
@@ -17,6 +17,7 @@ room = "2.7.0"
ksp = "2.1.10-1.0.31" ksp = "2.1.10-1.0.31"
playServicesAuth = "21.6.0" playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0" playServicesAuthApiPhone = "18.3.0"
playAppUpdate = "2.1.0"
junit = "4.13.2" junit = "4.13.2"
firebaseBom = "33.7.0" firebaseBom = "33.7.0"
googleServices = "4.4.2" googleServices = "4.4.2"
@@ -52,6 +53,8 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" } play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "playAppUpdate" }
play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "playAppUpdate" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
+472 -11
View File
@@ -7,11 +7,17 @@ VERSION_FILE="$ROOT_DIR/app/build.gradle.kts"
REMOTE_NAME="${GIT_REMOTE:-origin}" REMOTE_NAME="${GIT_REMOTE:-origin}"
NOTES_FILE="" NOTES_FILE=""
SKIP_TESTS=0 SKIP_TESTS=0
GOOGLE_PLAY_MODE="ask"
REUSE_CURRENT=0 REUSE_CURRENT=0
VERSION_FILE_CHANGED=0 VERSION_FILE_CHANGED=0
VERSION_COMMITTED=0 VERSION_COMMITTED=0
VERSION_BACKUP="" VERSION_BACKUP=""
RELEASE_SUCCEEDED=0 RELEASE_SUCCEEDED=0
GOOGLE_PLAY_ACCESS_TOKEN=""
GOOGLE_PLAY_EDIT_ID=""
GOOGLE_PLAY_TEMP_DIR=""
GOOGLE_PLAY_CURRENT_VERSION_STATE=""
GOOGLE_PLAY_SUMMARY="pominięto"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -21,13 +27,17 @@ Buduje i publikuje kolejną wersję aplikacji kierowcy:
- automatycznie zwiększa versionCode o 1 i patch versionName o 1, - automatycznie zwiększa versionCode o 1 i patch versionName o 1,
- uruchamia testy jednostkowe, - uruchamia testy jednostkowe,
- buduje podpisany APK dla Gitea Release, - buduje podpisany APK dla Gitea Release,
- buduje podpisany AAB dla Google Play Console, - buduje podpisany AAB i publikuje go w teście zamkniętym Google Play,
- wysyła zmiany Google Play do weryfikacji,
- zapisuje zmianę wersji w commicie, tworzy tag i wykonuje push, - zapisuje zmianę wersji w commicie, tworzy tag i wykonuje push,
- tworzy lub aktualizuje Gitea Release i wysyła APK oraz SHA256SUMS. - tworzy lub aktualizuje Gitea Release i wysyła APK oraz SHA256SUMS.
Opcje: Opcje:
--notes-file PLIK Treść release notes z podanego pliku. --notes-file PLIK Treść release notes z podanego pliku.
--skip-tests Pomiń testDebugUnitTest. --skip-tests Pomiń testDebugUnitTest.
--skip-google-play Nie pytaj i pomiń wysyłanie AAB do Google Play.
--publish-google-play
Nie pytaj i wyślij AAB do Google Play.
--reuse-current Nie zwiększaj wersji. Wznów publikację wersji zapisanej --reuse-current Nie zwiększaj wersji. Wznów publikację wersji zapisanej
obecnie w app/build.gradle.kts. obecnie w app/build.gradle.kts.
-h, --help Pokaż pomoc. -h, --help Pokaż pomoc.
@@ -37,9 +47,19 @@ Zmienne środowiskowe:
GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com. GITEA_BASE_URL Nadpisuje adres Gitea, np. https://gitea.example.com.
GITEA_REPOSITORY Nadpisuje owner/repository. GITEA_REPOSITORY Nadpisuje owner/repository.
GIT_REMOTE Remote Git, domyślnie origin. GIT_REMOTE Remote Git, domyślnie origin.
GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS
Opcjonalne nadpisanie automatycznie znalezionego klucza JSON.
Domyślnie skrypt znajduje konto serwisowe w scripts/*.json.
Alternatywnie: GOOGLE_APPLICATION_CREDENTIALS.
GOOGLE_PLAY_PACKAGE_NAME
Identyfikator aplikacji, domyślnie pl.firmatpp.kierowca.
GOOGLE_PLAY_TRACK Nazwa testu zamkniętego, domyślnie alpha.
GOOGLE_PLAY_RELEASE_LANGUAGE
Język informacji o wersji, domyślnie pl-PL.
Wersja początkowa jest zawsze odczytywana z app/build.gradle.kts. Wersja początkowa jest zawsze odczytywana z app/build.gradle.kts.
Google Play Console otrzymuje plik AAB, ponieważ jest to właściwy format publikacyjny. Google Play otrzymuje AAB przez Android Publisher API. Konto serwisowe musi mieć
uprawnienie „Publikowanie aplikacji na ścieżkach testowych”.
EOF EOF
} }
@@ -54,6 +74,14 @@ while (($# > 0)); do
SKIP_TESTS=1 SKIP_TESTS=1
shift shift
;; ;;
--skip-google-play)
GOOGLE_PLAY_MODE="skip"
shift
;;
--publish-google-play)
GOOGLE_PLAY_MODE="publish"
shift
;;
--reuse-current) --reuse-current)
REUSE_CURRENT=1 REUSE_CURRENT=1
shift shift
@@ -82,6 +110,10 @@ done
[[ -x ./gradlew ]] || { echo "Brak wykonywalnego ./gradlew." >&2; exit 1; } [[ -x ./gradlew ]] || { echo "Brak wykonywalnego ./gradlew." >&2; exit 1; }
[[ -f keystore.properties ]] || { echo "Brak keystore.properties wymaganego do podpisania release." >&2; exit 1; } [[ -f keystore.properties ]] || { echo "Brak keystore.properties wymaganego do podpisania release." >&2; exit 1; }
[[ -f "$VERSION_FILE" ]] || { echo "Brak $VERSION_FILE." >&2; exit 1; } [[ -f "$VERSION_FILE" ]] || { echo "Brak $VERSION_FILE." >&2; exit 1; }
if [[ -n "$NOTES_FILE" && ! -f "$NOTES_FILE" ]]; then
echo "Nie istnieje plik release notes: $NOTES_FILE" >&2
exit 1
fi
branch="$(git branch --show-current)" branch="$(git branch --show-current)"
[[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; } [[ -n "$branch" ]] || { echo "Release nie może być wykonany z detached HEAD." >&2; exit 1; }
@@ -106,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=""
@@ -115,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" ]] || {
@@ -165,11 +211,24 @@ play_aab_name="TPP-Kierowca-${next_name}-${next_code}.aab"
cleanup_on_exit() { cleanup_on_exit() {
local exit_code=$? local exit_code=$?
if ((RELEASE_SUCCEEDED == 0)) && [[ -n "$GOOGLE_PLAY_EDIT_ID" && -n "$GOOGLE_PLAY_ACCESS_TOKEN" ]]; then
package_encoded="$(urlencode "$google_play_package")"
edit_encoded="$(urlencode "$GOOGLE_PLAY_EDIT_ID")"
curl -sS -o /dev/null \
--connect-timeout 20 \
--max-time 60 \
-X DELETE \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded/edits/$edit_encoded" || true
fi
if ((RELEASE_SUCCEEDED == 0 && VERSION_FILE_CHANGED == 1 && VERSION_COMMITTED == 0)) && [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]]; then if ((RELEASE_SUCCEEDED == 0 && VERSION_FILE_CHANGED == 1 && VERSION_COMMITTED == 0)) && [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]]; then
cp "$VERSION_BACKUP" "$VERSION_FILE" cp "$VERSION_BACKUP" "$VERSION_FILE"
echo "Przywrócono poprzednią wersję w app/build.gradle.kts." >&2 echo "Przywrócono poprzednią wersję w app/build.gradle.kts." >&2
fi fi
[[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP" [[ -n "$VERSION_BACKUP" && -f "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP"
if [[ -n "$GOOGLE_PLAY_TEMP_DIR" && -d "$GOOGLE_PLAY_TEMP_DIR" ]]; then
rm -rf -- "$GOOGLE_PLAY_TEMP_DIR"
fi
if ((RELEASE_SUCCEEDED == 0)); then if ((RELEASE_SUCCEEDED == 0)); then
echo "Release nie został ukończony. Po usunięciu przyczyny użyj --reuse-current, jeśli commit wersji już powstał." >&2 echo "Release nie został ukończony. Po usunięciu przyczyny użyj --reuse-current, jeśli commit wersji już powstał." >&2
fi fi
@@ -180,6 +239,391 @@ trap cleanup_on_exit EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
urlencode() {
jq -rn --arg value "$1" '$value | @uri'
}
base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
show_google_play_error() {
local response_file="$1"
jq '.error | {code, message, status, details}' "$response_file" >&2 2>/dev/null || true
}
decide_google_play_upload() {
local answer
case "$GOOGLE_PLAY_MODE" in
publish)
return 0
;;
skip)
GOOGLE_PLAY_SUMMARY="pominięto (--skip-google-play)"
return 0
;;
ask)
if [[ ! -t 0 ]]; then
echo "Nie można zapytać o publikację Google Play bez interaktywnego terminala." >&2
echo "Użyj --publish-google-play albo --skip-google-play." >&2
return 1
fi
while true; do
printf "\nBuild %s (%s) zakończony poprawnie. Czy wysłać aktualizację do Google Play? [t/N] " "$next_name" "$next_code"
if ! IFS= read -r answer; then
echo "Nie udało się odczytać odpowiedzi." >&2
return 1
fi
case "${answer,,}" in
t|tak|y|yes)
GOOGLE_PLAY_MODE="publish"
break
;;
""|n|nie|no)
GOOGLE_PLAY_MODE="skip"
GOOGLE_PLAY_SUMMARY="pominięto przez użytkownika"
break
;;
*)
echo "Nie rozpoznano odpowiedzi. Wpisz tak albo nie." >&2
;;
esac
done
;;
*)
echo "Niepoprawny tryb publikacji Google Play: $GOOGLE_PLAY_MODE" >&2
return 1
;;
esac
}
prepare_google_play() {
local credentials_path client_email now expires header payload unsigned_jwt signature assertion previous_umask
local token_response token_status preflight_response preflight_status package_encoded track_encoded
local candidate
local -a credentials_candidates=()
google_play_package="${GOOGLE_PLAY_PACKAGE_NAME:-pl.firmatpp.kierowca}"
google_play_track="${GOOGLE_PLAY_TRACK:-alpha}"
google_play_release_language="${GOOGLE_PLAY_RELEASE_LANGUAGE:-pl-PL}"
credentials_path="${GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS:-${GOOGLE_APPLICATION_CREDENTIALS:-}}"
if [[ -z "$credentials_path" ]]; then
while IFS= read -r -d '' candidate; do
if jq -e '
.type == "service_account"
and (.client_email | type == "string" and length > 0)
and (.private_key | type == "string" and length > 0)
' "$candidate" >/dev/null 2>&1; then
credentials_candidates+=("$candidate")
fi
done < <(find "$ROOT_DIR/scripts" -maxdepth 1 -type f -name '*.json' -print0)
case "${#credentials_candidates[@]}" in
0)
echo "Nie znaleziono klucza konta serwisowego w $ROOT_DIR/scripts/*.json." >&2
echo "Umieść dokładnie jeden poprawny plik JSON konta serwisowego w folderze scripts." >&2
return 1
;;
1)
credentials_path="${credentials_candidates[0]}"
;;
*)
echo "Znaleziono więcej niż jeden klucz konta serwisowego w folderze scripts:" >&2
for candidate in "${credentials_candidates[@]}"; do
printf ' - %s\n' "$(basename "$candidate")" >&2
done
echo "Pozostaw jeden plik albo ustaw GOOGLE_PLAY_SERVICE_ACCOUNT_CREDENTIALS." >&2
return 1
;;
esac
fi
command -v openssl >/dev/null 2>&1 || {
echo "Brak wymaganego polecenia: openssl" >&2
return 1
}
[[ "$google_play_package" =~ ^[A-Za-z0-9._]+$ ]] || {
echo "Niepoprawne GOOGLE_PLAY_PACKAGE_NAME: $google_play_package" >&2
return 1
}
[[ -n "$google_play_track" ]] || { echo "GOOGLE_PLAY_TRACK nie może być puste." >&2; return 1; }
[[ "$google_play_release_language" =~ ^[A-Za-z0-9-]+$ ]] || {
echo "Niepoprawne GOOGLE_PLAY_RELEASE_LANGUAGE: $google_play_release_language" >&2
return 1
}
[[ -n "$credentials_path" ]] || {
echo "Nie wskazano ani nie znaleziono klucza konta serwisowego Google Play." >&2
return 1
}
[[ -r "$credentials_path" ]] || {
echo "Nie można odczytać klucza konta serwisowego: $credentials_path" >&2
return 1
}
GOOGLE_PLAY_TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/tpp-google-play.XXXXXX")"
chmod 700 "$GOOGLE_PLAY_TEMP_DIR"
client_email="$(jq -er '.client_email | select(type == "string" and length > 0)' "$credentials_path")" || {
echo "Klucz Google Play nie zawiera client_email." >&2
return 1
}
previous_umask="$(umask)"
umask 077
if ! jq -er '.private_key | select(type == "string" and length > 0)' "$credentials_path" > "$GOOGLE_PLAY_TEMP_DIR/private-key.pem"; then
umask "$previous_umask"
echo "Klucz Google Play nie zawiera private_key." >&2
return 1
fi
umask "$previous_umask"
now="$(date +%s)"
expires=$((now + 3600))
header="$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | base64url)"
payload="$(jq -cn \
--arg iss "$client_email" \
--arg scope 'https://www.googleapis.com/auth/androidpublisher' \
--arg aud 'https://oauth2.googleapis.com/token' \
--argjson iat "$now" \
--argjson exp "$expires" \
'{iss:$iss,scope:$scope,aud:$aud,iat:$iat,exp:$exp}' | base64url)"
unsigned_jwt="$header.$payload"
signature="$(printf '%s' "$unsigned_jwt" \
| openssl dgst -sha256 -sign "$GOOGLE_PLAY_TEMP_DIR/private-key.pem" \
| base64url)"
assertion="$unsigned_jwt.$signature"
printf '%s' "$assertion" > "$GOOGLE_PLAY_TEMP_DIR/assertion.txt"
chmod 600 "$GOOGLE_PLAY_TEMP_DIR/assertion.txt"
token_response="$GOOGLE_PLAY_TEMP_DIR/token.json"
token_status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$token_response" \
-w '%{http_code}' \
-X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
--data-urlencode "assertion@$GOOGLE_PLAY_TEMP_DIR/assertion.txt" \
'https://oauth2.googleapis.com/token')"
[[ "$token_status" == "200" ]] || {
echo "Nie udało się pobrać tokenu Google Play (HTTP $token_status)." >&2
jq '{error, error_description}' "$token_response" >&2 2>/dev/null || true
return 1
}
GOOGLE_PLAY_ACCESS_TOKEN="$(jq -er '.access_token | select(type == "string" and length > 0)' "$token_response")" || {
echo "Google OAuth nie zwrócił access_token." >&2
return 1
}
printf 'Authorization: Bearer %s\n' "$GOOGLE_PLAY_ACCESS_TOKEN" > "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
chmod 600 "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
rm -f "$GOOGLE_PLAY_TEMP_DIR/private-key.pem" "$GOOGLE_PLAY_TEMP_DIR/assertion.txt" "$token_response"
package_encoded="$(urlencode "$google_play_package")"
track_encoded="$(urlencode "$google_play_track")"
preflight_response="$GOOGLE_PLAY_TEMP_DIR/preflight.json"
preflight_status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$preflight_response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded/tracks/$track_encoded/releases")"
[[ "$preflight_status" == "200" ]] || {
echo "Brak dostępu do ścieżki Google Play $google_play_track dla $google_play_package (HTTP $preflight_status)." >&2
show_google_play_error "$preflight_response"
return 1
}
GOOGLE_PLAY_CURRENT_VERSION_STATE="$(jq -r --argjson code "$next_code" '
.releases[]?
| select(any(.activeArtifacts[]?; .versionCode == $code))
| .releaseLifecycleState // empty
' "$preflight_response" | sed -n '1p')"
if [[ -n "$GOOGLE_PLAY_CURRENT_VERSION_STATE" && "$REUSE_CURRENT" == "0" ]]; then
echo "Google Play ma już versionCode $next_code na ścieżce $google_play_track ($GOOGLE_PLAY_CURRENT_VERSION_STATE)." >&2
echo "Zwiększ wersję lokalną albo świadomie użyj --reuse-current." >&2
return 1
fi
if [[ "$GOOGLE_PLAY_CURRENT_VERSION_STATE" == "RELEASE_LIFECYCLE_STATE_NOT_APPROVED" ]]; then
echo "Wersja $next_code została odrzucona w Google Play. Zwiększ versionCode i utwórz nowe wydanie." >&2
return 1
fi
echo "Google Play: używam klucza $(basename "$credentials_path")."
echo "Google Play: zweryfikowano dostęp do $google_play_package, ścieżka $google_play_track."
}
publish_to_google_play() {
local release_notes="$1"
local package_encoded track_encoded edit_encoded api_base
local response status bundle_version_code existing_sha256 local_sha256
local play_notes track_payload release_name
case "$GOOGLE_PLAY_CURRENT_VERSION_STATE" in
RELEASE_LIFECYCLE_STATE_IN_REVIEW|RELEASE_LIFECYCLE_STATE_APPROVED_NOT_PUBLISHED|RELEASE_LIFECYCLE_STATE_PUBLISHED)
GOOGLE_PLAY_SUMMARY="już istnieje: $GOOGLE_PLAY_CURRENT_VERSION_STATE"
echo "Google Play: wersja $next_name ($next_code) ma już stan $GOOGLE_PLAY_CURRENT_VERSION_STATE; pomijam ponowną publikację."
return 0
;;
esac
package_encoded="$(urlencode "$google_play_package")"
track_encoded="$(urlencode "$google_play_track")"
api_base="https://androidpublisher.googleapis.com/androidpublisher/v3/applications/$package_encoded"
response="$GOOGLE_PLAY_TEMP_DIR/edit-create.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/json' \
-d '{}' \
"$api_base/edits")"
[[ "$status" == "200" ]] || {
echo "Nie udało się utworzyć edycji Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
GOOGLE_PLAY_EDIT_ID="$(jq -er '.id | select(type == "string" and length > 0)' "$response")" || {
echo "Google Play nie zwrócił identyfikatora edycji." >&2
return 1
}
edit_encoded="$(urlencode "$GOOGLE_PLAY_EDIT_ID")"
response="$GOOGLE_PLAY_TEMP_DIR/track-get.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded/tracks/$track_encoded")"
[[ "$status" == "200" ]] || {
echo "Nie znaleziono ścieżki Google Play $google_play_track (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/bundles.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded/bundles")"
[[ "$status" == "200" ]] || {
echo "Nie udało się sprawdzić pakietów Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
existing_sha256="$(jq -r --argjson code "$next_code" '.bundles[]? | select(.versionCode == $code) | .sha256 // empty' "$response" | sed -n '1p')"
local_sha256="$(sha256sum "$artifact_dir/google-play/$play_aab_name" | awk '{print $1}')"
if [[ -n "$existing_sha256" ]]; then
if [[ "${existing_sha256,,}" != "${local_sha256,,}" && "$REUSE_CURRENT" == "0" ]]; then
echo "Google Play ma już versionCode $next_code z inną sumą SHA-256." >&2
return 1
fi
if [[ "${existing_sha256,,}" != "${local_sha256,,}" ]]; then
echo "Uwaga: lokalny AAB ma inną sumę niż istniejący versionCode $next_code; --reuse-current zachowuje pakiet już wysłany do Google Play." >&2
fi
bundle_version_code="$next_code"
echo "Google Play ma już AAB $next_name ($next_code); pomijam ponowny upload."
else
response="$GOOGLE_PLAY_TEMP_DIR/bundle-upload.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 600 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/octet-stream' \
--data-binary "@$artifact_dir/google-play/$play_aab_name" \
"https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/$package_encoded/edits/$edit_encoded/bundles?uploadType=media")"
[[ "$status" == "200" ]] || {
echo "Nie udało się wysłać AAB do Google Play (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
bundle_version_code="$(jq -er '.versionCode' "$response")" || {
echo "Google Play nie zwrócił versionCode przesłanego AAB." >&2
return 1
}
[[ "$bundle_version_code" == "$next_code" ]] || {
echo "Google Play odczytał versionCode $bundle_version_code zamiast $next_code." >&2
return 1
}
fi
play_notes="$(jq -rn --arg notes "$release_notes" '$notes | if length > 500 then .[0:497] + "..." else . end')"
release_name="TPP Kierowca $next_name ($next_code)"
track_payload="$(jq -n \
--arg track "$google_play_track" \
--arg release_name "$release_name" \
--arg version_code "$bundle_version_code" \
--arg language "$google_play_release_language" \
--arg notes "$play_notes" \
'{track:$track,releases:[{name:$release_name,versionCodes:[$version_code],status:"completed",releaseNotes:[{language:$language,text:$notes}]}]}')"
response="$GOOGLE_PLAY_TEMP_DIR/track-update.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 60 \
-o "$response" \
-w '%{http_code}' \
-X PUT \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
-H 'Content-Type: application/json' \
-d "$track_payload" \
"$api_base/edits/$edit_encoded/tracks/$track_encoded")"
[[ "$status" == "200" ]] || {
echo "Nie udało się przypisać AAB do ścieżki $google_play_track (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/edit-validate.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 120 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded:validate")"
[[ "$status" == "200" ]] || {
echo "Walidacja edycji Google Play nie powiodła się (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
response="$GOOGLE_PLAY_TEMP_DIR/edit-commit.json"
status="$(curl -sS \
--connect-timeout 20 \
--max-time 120 \
-o "$response" \
-w '%{http_code}' \
-X POST \
-H "@$GOOGLE_PLAY_TEMP_DIR/auth-header.txt" \
"$api_base/edits/$edit_encoded:commit?changesNotSentForReview=false&changesInReviewBehavior=ERROR_IF_IN_REVIEW")"
[[ "$status" == "200" ]] || {
echo "Nie udało się zatwierdzić i wysłać zmian Google Play do weryfikacji (HTTP $status)." >&2
show_google_play_error "$response"
return 1
}
GOOGLE_PLAY_EDIT_ID=""
GOOGLE_PLAY_SUMMARY="wysłano do weryfikacji"
echo "Google Play: $release_name wysłano na ścieżkę $google_play_track i przekazano do weryfikacji."
}
if ((REUSE_CURRENT == 0)); then if ((REUSE_CURRENT == 0)); then
if git show-ref --verify --quiet "refs/tags/$tag_name" || git ls-remote --exit-code --tags "$REMOTE_NAME" "refs/tags/$tag_name" >/dev/null 2>&1; then if git show-ref --verify --quiet "refs/tags/$tag_name" || git ls-remote --exit-code --tags "$REMOTE_NAME" "refs/tags/$tag_name" >/dev/null 2>&1; then
echo "Tag $tag_name już istnieje. Użyj --reuse-current albo zwiększ wersję ręcznie." >&2 echo "Tag $tag_name już istnieje. Użyj --reuse-current albo zwiększ wersję ręcznie." >&2
@@ -235,14 +679,18 @@ cp "$aab_source" "$artifact_dir/google-play/$play_aab_name"
sha256sum "gitea/$gitea_apk_name" "google-play/$play_aab_name" > SHA256SUMS.txt sha256sum "gitea/$gitea_apk_name" "google-play/$play_aab_name" > SHA256SUMS.txt
) )
decide_google_play_upload
if [[ "$GOOGLE_PLAY_MODE" == "publish" ]]; then
prepare_google_play
fi
if ((REUSE_CURRENT == 0)); then if ((REUSE_CURRENT == 0)); then
git add "$VERSION_FILE" git add "$VERSION_FILE"
git diff --cached --check git diff --cached --check
git commit -m "chore: release android driver $next_name ($next_code)" git commit -m "Wydaj aplikację kierowcy $next_name ($next_code)"
VERSION_COMMITTED=1 VERSION_COMMITTED=1
fi fi
git push "$REMOTE_NAME" "$branch"
if ! git show-ref --verify --quiet "refs/tags/$tag_name"; then if ! git show-ref --verify --quiet "refs/tags/$tag_name"; then
git tag -a "$tag_name" -m "TPP Kierowca $next_name ($next_code)" git tag -a "$tag_name" -m "TPP Kierowca $next_name ($next_code)"
fi fi
@@ -252,10 +700,8 @@ head_commit="$(git rev-parse HEAD)"
echo "Tag $tag_name nie wskazuje aktualnego commita ($head_commit)." >&2 echo "Tag $tag_name nie wskazuje aktualnego commita ($head_commit)." >&2
exit 1 exit 1
} }
git push "$REMOTE_NAME" "$tag_name"
if [[ -n "$NOTES_FILE" ]]; then if [[ -n "$NOTES_FILE" ]]; then
[[ -f "$NOTES_FILE" ]] || { echo "Nie istnieje plik release notes: $NOTES_FILE" >&2; exit 1; }
release_notes="$(<"$NOTES_FILE")" release_notes="$(<"$NOTES_FILE")"
else else
previous_tag="$(git tag --sort=-version:refname | grep -Fxv "$tag_name" | sed -n '1p' || true)" previous_tag="$(git tag --sort=-version:refname | grep -Fxv "$tag_name" | sed -n '1p' || true)"
@@ -264,7 +710,7 @@ else
else else
changes="$(git log -20 --format='- %s (%h)')" changes="$(git log -20 --format='- %s (%h)')"
fi fi
release_notes="TPP Kierowca $next_name ($next_code) release_notes="TPP Kierowca $next_name ($next_code)
Zmiany: Zmiany:
${changes:-'- Wydanie techniczne.'} ${changes:-'- Wydanie techniczne.'}
@@ -273,6 +719,15 @@ Artefakt Google Play Console (AAB) znajduje się lokalnie w:
release-artifacts/$tag_name/google-play/$play_aab_name" release-artifacts/$tag_name/google-play/$play_aab_name"
fi fi
if [[ "$GOOGLE_PLAY_MODE" == "publish" ]]; then
publish_to_google_play "$release_notes"
GOOGLE_PLAY_ACCESS_TOKEN=""
rm -f "$GOOGLE_PLAY_TEMP_DIR/auth-header.txt"
fi
git push "$REMOTE_NAME" "$branch"
git push "$REMOTE_NAME" "$tag_name"
release_response="$(mktemp)" release_response="$(mktemp)"
release_status="$(curl -sS "${auth_args[@]}" -o "$release_response" -w '%{http_code}' "$api_base/releases/tags/$tag_name")" release_status="$(curl -sS "${auth_args[@]}" -o "$release_response" -w '%{http_code}' "$api_base/releases/tags/$tag_name")"
if [[ "$release_status" == "200" ]]; then if [[ "$release_status" == "200" ]]; then
@@ -348,8 +803,12 @@ published_apk_hash="$(sha256sum "$downloaded_apk" | awk '{print $1}')"
release_url="$gitea_base_url/$gitea_repository/releases/tag/$tag_name" release_url="$gitea_base_url/$gitea_repository/releases/tag/$tag_name"
RELEASE_SUCCEEDED=1 RELEASE_SUCCEEDED=1
trap - EXIT INT TERM
[[ -n "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP" [[ -n "$VERSION_BACKUP" ]] && rm -f "$VERSION_BACKUP"
if [[ -n "$GOOGLE_PLAY_TEMP_DIR" && -d "$GOOGLE_PLAY_TEMP_DIR" ]]; then
rm -rf -- "$GOOGLE_PLAY_TEMP_DIR"
GOOGLE_PLAY_TEMP_DIR=""
fi
trap - EXIT INT TERM
cat <<EOF cat <<EOF
@@ -358,6 +817,8 @@ Release zakończony:
Tag: $tag_name Tag: $tag_name
Gitea: $release_url Gitea: $release_url
APK: $artifact_dir/gitea/$gitea_apk_name APK: $artifact_dir/gitea/$gitea_apk_name
Google Play: $artifact_dir/google-play/$play_aab_name AAB: $artifact_dir/google-play/$play_aab_name
Google Play: $GOOGLE_PLAY_SUMMARY
Play track: ${google_play_track:-pominięto}
Checksumy: $artifact_dir/SHA256SUMS.txt Checksumy: $artifact_dir/SHA256SUMS.txt
EOF EOF