Compare commits

...
8 Commits
Author SHA1 Message Date
admin 541dda292f Add photo upload failure details 2026-07-01 22:47:13 +02:00
admin fa98451533 Add photo upload retry controls 2026-07-01 22:39:47 +02:00
admin ee9d8cdbaf Update driver app photo handling 2026-07-01 19:26:28 +02:00
admin 76326bbc27 Fix launcher icon clipping for 1.0.22 2026-07-01 14:49:11 +02:00
admin ed02da031b Refine launcher icon for 1.0.21 2026-07-01 14:44:06 +02:00
admin a4e24da717 Fix launcher icon safe area for 1.0.20 2026-07-01 14:20:54 +02:00
admin ab84205bec Update Android app icon for 1.0.18 2026-07-01 12:46:38 +02:00
admin 9ba1b1fa13 Dodaj live sync i kolejkę zdjęć 2026-07-01 10:59:21 +02:00
70 changed files with 1896 additions and 127 deletions
+4
View File
@@ -1,5 +1,9 @@
.gradle/
build/
local.properties
app/google-services.json
app/google-services.json:Zone.Identifier
*/build/
*.iml
keystore.properties
*.jks
+49 -2
View File
@@ -1,3 +1,6 @@
import java.io.File
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -5,19 +8,57 @@ plugins {
alias(libs.plugins.ksp)
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use(::load)
}
}
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
fun configValue(name: String, default: String): String =
providers.gradleProperty(name)
.orElse(providers.environmentVariable(name))
.orElse(default)
.get()
defaultConfig {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 17
versionName = "1.0.16"
versionCode = 29
versionName = "1.0.28"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
buildConfigField("String", "REVERB_APP_KEY", "\"${configValue("REVERB_APP_KEY", "")}\"")
buildConfigField("String", "REVERB_WS_BASE_URL", "\"${configValue("REVERB_WS_BASE_URL", "wss://api-intranet.firmatpp.pl/app/")}\"")
}
signingConfigs {
create("release") {
fun requiredSigningProperty(name: String): String =
keystoreProperties.getProperty(name)
?: throw GradleException("Missing '$name' in keystore.properties")
val configuredStoreFile = requiredSigningProperty("storeFile")
storeFile = if (File(configuredStoreFile).isAbsolute) {
file(configuredStoreFile)
} else {
rootProject.file(configuredStoreFile)
}
storePassword = requiredSigningProperty("storePassword")
keyAlias = requiredSigningProperty("keyAlias")
keyPassword = requiredSigningProperty("keyPassword")
}
}
buildTypes {
@@ -25,7 +66,11 @@ android {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -77,6 +122,8 @@ dependencies {
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.coroutines.android)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.play.services.auth)
+8
View File
@@ -30,5 +30,13 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".sync.DriverFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -20,6 +20,7 @@ data class ApiError(
val retryable: Boolean,
val code: String? = null,
val statusCode: Int? = null,
val responseBody: String? = null,
)
object ApiErrorMapper {
@@ -48,10 +49,17 @@ object ApiErrorMapper {
code = code,
message = message,
retryable = retryable,
responseBody = body,
)
}
fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError {
fun mapProblem(
statusCode: Int,
code: String?,
message: String?,
retryable: Boolean?,
responseBody: String? = null,
): ApiError {
val kind = when (statusCode) {
401 -> ApiErrorKind.Auth
403 -> ApiErrorKind.Forbidden
@@ -80,6 +88,7 @@ object ApiErrorMapper {
retryable = resolvedRetryable,
code = code,
statusCode = statusCode,
responseBody = responseBody,
)
}
@@ -12,11 +12,15 @@ import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
@@ -42,6 +46,24 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
api.syncState(authHeader(requireToken()), date, routeId)
suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse =
api.broadcastAuth(authHeader(requireToken()), BroadcastAuthBody(socketId, channelName))
suspend fun storePushToken(token: String) {
api.storePushToken(authHeader(requireToken()), PushTokenBody(token))
}
suspend fun deletePushToken() {
val token = tokenStore.read() ?: return
runCatching { api.deletePushToken(authHeader(token)) }
}
suspend fun uploadPhoto(
routeId: String,
uri: Uri,
@@ -106,6 +128,7 @@ class DriverRepository(
suspend fun logout() {
val token = tokenStore.read()
if (token != null) {
runCatching { api.deletePushToken(authHeader(token)) }
runCatching { api.logout(authHeader(token)) }
}
tokenStore.clear()
@@ -3,10 +3,15 @@ package pl.firmatpp.kierowca.data.api
import okhttp3.MultipartBody
import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.model.VerifyOtpResponse
import retrofit2.http.Body
@@ -15,6 +20,7 @@ import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
@@ -41,6 +47,36 @@ interface MobileDriverApi {
@Path("routeId") routeId: String,
): RouteResponse
@POST("mobile/driver/routes/{routeId}/complete")
suspend fun completeRoute(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
): CompleteRouteResponse
@GET("mobile/driver/sync-state")
suspend fun syncState(
@Header("Authorization") authorization: String,
@Query("date") date: String?,
@Query("routeId") routeId: String?,
): SyncStateResponse
@POST("mobile/driver/broadcasting/auth")
suspend fun broadcastAuth(
@Header("Authorization") authorization: String,
@Body body: BroadcastAuthBody,
): BroadcastAuthResponse
@PUT("mobile/driver/push-token")
suspend fun storePushToken(
@Header("Authorization") authorization: String,
@Body body: PushTokenBody,
): Map<String, Boolean>
@DELETE("mobile/driver/push-token")
suspend fun deletePushToken(
@Header("Authorization") authorization: String,
): Map<String, Boolean>
@Multipart
@POST("mobile/driver/routes/{routeId}/photos")
suspend fun uploadPhoto(
@@ -46,6 +46,7 @@ data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val syncState: SyncStateResponse? = null,
)
data class DriverAppSettingsDto(
@@ -56,6 +57,8 @@ data class DriverAppSettingsDto(
val maxPastDays: Int,
val autoRefreshSeconds: Int,
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
)
data class DriverSessionDto(
@@ -69,6 +72,42 @@ data class RoutesBucketDto(
data class RouteResponse(
val route: DriverRouteDto,
val syncState: SyncStateResponse? = null,
)
data class CompleteRouteResponse(
val ok: Boolean,
val alreadyCompleted: Boolean,
val message: String?,
val completedAt: String?,
val route: DriverRouteDto,
)
data class SyncStateResponse(
val scopes: List<SyncScopeDto> = emptyList(),
)
data class SyncScopeDto(
val scope: String,
val date: String? = null,
val routeId: String? = null,
val version: Long,
val checksum: String,
val computedAt: String,
)
data class BroadcastAuthBody(
val socket_id: String,
val channel_name: String,
)
data class BroadcastAuthResponse(
val auth: String,
)
data class PushTokenBody(
val token: String,
val platform: String = "android",
)
data class DriverRouteDto(
@@ -84,6 +123,8 @@ data class DriverRouteDto(
val distanceKm: Double?,
val notes: String?,
val truck: TruckDto?,
val completedAt: String? = null,
val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null,
val photos: List<RoutePhotoDto> = emptyList(),
@@ -0,0 +1,36 @@
package pl.firmatpp.kierowca.data.sync
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface DriverCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertBootstrap(cache: DriverBootstrapCacheEntity)
@Query("SELECT * FROM driver_bootstrap_cache WHERE date = :date LIMIT 1")
suspend fun bootstrap(date: String): DriverBootstrapCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRoute(cache: DriverRouteCacheEntity)
@Query("SELECT * FROM driver_route_cache WHERE routeId = :routeId LIMIT 1")
suspend fun route(routeId: String): DriverRouteCacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertSyncState(state: DriverSyncStateEntity)
@Query("SELECT * FROM driver_sync_states WHERE scope = :scope AND ifnull(date, '-') = ifnull(:date, '-') AND ifnull(routeId, '-') = ifnull(:routeId, '-') LIMIT 1")
suspend fun syncState(scope: String, date: String?, routeId: String?): DriverSyncStateEntity?
@Query("DELETE FROM driver_bootstrap_cache")
suspend fun clearBootstrap()
@Query("DELETE FROM driver_route_cache")
suspend fun clearRoutes()
@Query("DELETE FROM driver_sync_states")
suspend fun clearSyncStates()
}
@@ -0,0 +1,49 @@
package pl.firmatpp.kierowca.data.sync
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "driver_bootstrap_cache")
data class DriverBootstrapCacheEntity(
@PrimaryKey val date: String,
val payloadJson: String,
val checksum: String?,
val version: Long?,
val syncedAtEpochMillis: Long,
)
@Entity(tableName = "driver_route_cache")
data class DriverRouteCacheEntity(
@PrimaryKey val routeId: String,
val payloadJson: String,
val checksum: String?,
val version: Long?,
val syncedAtEpochMillis: Long,
)
@Entity(
tableName = "driver_sync_states",
indices = [
Index(value = ["scope", "date", "routeId"], unique = true),
],
)
data class DriverSyncStateEntity(
@PrimaryKey val key: String,
val scope: String,
val date: String?,
val routeId: String?,
val checksum: String,
val version: Long,
val computedAt: String,
val syncedAtEpochMillis: Long,
)
data class CachedValue<T>(
val value: T,
val stale: Boolean,
val syncedAtEpochMillis: Long?,
)
fun syncStateKey(scope: String, date: String?, routeId: String?): String =
listOf(scope, date ?: "-", routeId ?: "-").joinToString(":")
@@ -0,0 +1,154 @@
package pl.firmatpp.kierowca.data.sync
import android.content.Context
import com.google.gson.Gson
import java.io.IOException
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncScopeDto
import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.upload.DriverDatabase
import retrofit2.HttpException
class DriverSyncRepository(
context: Context,
private val repository: DriverRepository = DriverRepository(context),
private val dao: DriverCacheDao = DriverDatabase.get(context).driverCacheDao(),
private val gson: Gson = Gson(),
) {
suspend fun bootstrap(date: String?): CachedValue<BootstrapResponse> {
val requestedDate = date ?: currentDateFallback()
return runCatching {
val response = repository.bootstrap(date)
val selectedDate = response.driverAppSettings?.selectedDate ?: requestedDate
val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTES }
dao.upsertBootstrap(
DriverBootstrapCacheEntity(
date = selectedDate,
payloadJson = gson.toJson(response),
checksum = routeState?.checksum,
version = routeState?.version,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
cachedBootstrap(requestedDate, throwable)
}
}
suspend fun route(routeId: String): CachedValue<RouteResponse> =
runCatching {
val response = repository.route(routeId)
val routeState = response.syncState?.scopes?.firstOrNull { it.scope == SCOPE_ROUTE_DETAIL }
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = routeId,
payloadJson = gson.toJson(response),
checksum = routeState?.checksum,
version = routeState?.version,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
saveSyncStates(response.syncState)
CachedValue(response, stale = false, syncedAtEpochMillis = System.currentTimeMillis())
}.getOrElse { throwable ->
cachedRoute(routeId, throwable)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = route.id,
payloadJson = gson.toJson(RouteResponse(route = route)),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
repository.syncState(date, routeId)
suspend fun shouldRefresh(scope: SyncScopeDto): Boolean {
val local = dao.syncState(scope.scope, scope.date, scope.routeId)
return local == null || local.checksum != scope.checksum || local.version < scope.version
}
suspend fun saveSyncStates(response: SyncStateResponse?) {
response?.scopes.orEmpty().forEach { scope ->
dao.upsertSyncState(
DriverSyncStateEntity(
key = syncStateKey(scope.scope, scope.date, scope.routeId),
scope = scope.scope,
date = scope.date,
routeId = scope.routeId,
checksum = scope.checksum,
version = scope.version,
computedAt = scope.computedAt,
syncedAtEpochMillis = System.currentTimeMillis(),
),
)
}
}
suspend fun clearCache() {
dao.clearBootstrap()
dao.clearRoutes()
dao.clearSyncStates()
}
private suspend fun cachedBootstrap(date: String, throwable: Throwable): CachedValue<BootstrapResponse> {
val cached = dao.bootstrap(date)
if (cached != null && throwable.isReadFallbackAllowed()) {
return CachedValue(
gson.fromJson(cached.payloadJson, BootstrapResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
throw throwable
}
private suspend fun cachedRoute(routeId: String, throwable: Throwable): CachedValue<RouteResponse> {
val cached = dao.route(routeId)
if (cached != null && throwable.isReadFallbackAllowed()) {
return CachedValue(
gson.fromJson(cached.payloadJson, RouteResponse::class.java),
stale = true,
syncedAtEpochMillis = cached.syncedAtEpochMillis,
)
}
throw throwable
}
private fun Throwable.isReadFallbackAllowed(): Boolean =
this is IOException || (this is HttpException && code() != 401 && code() != 403)
private fun currentDateFallback(): String = java.time.LocalDate.now().toString()
companion object {
const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings"
}
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.data.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
class NetworkMonitor(context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: Flow<Boolean> = callbackFlow {
fun current(): Boolean = connectivityManager.activeNetwork
?.let(connectivityManager::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(current())
}
override fun onLost(network: Network) {
trySend(current())
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
trySend(current())
}
}
trySend(current())
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
@@ -1,29 +1,83 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.room.migration.Migration
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import pl.firmatpp.kierowca.data.sync.DriverBootstrapCacheEntity
import pl.firmatpp.kierowca.data.sync.DriverCacheDao
import pl.firmatpp.kierowca.data.sync.DriverRouteCacheEntity
import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
@Database(
entities = [PhotoUploadEntity::class],
version = 1,
entities = [
PhotoUploadEntity::class,
DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class,
DriverSyncStateEntity::class,
],
version = 2,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao
abstract fun driverCacheDao(): DriverCacheDao
companion object {
@Volatile
private var instance: DriverDatabase? = null
private val migration1To2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_bootstrap_cache (
date TEXT NOT NULL PRIMARY KEY,
payloadJson TEXT NOT NULL,
checksum TEXT,
version INTEGER,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_route_cache (
routeId TEXT NOT NULL PRIMARY KEY,
payloadJson TEXT NOT NULL,
checksum TEXT,
version INTEGER,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS driver_sync_states (
key TEXT NOT NULL PRIMARY KEY,
scope TEXT NOT NULL,
date TEXT,
routeId TEXT,
checksum TEXT NOT NULL,
version INTEGER NOT NULL,
computedAt TEXT NOT NULL,
syncedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_driver_sync_states_scope_date_routeId ON driver_sync_states(scope, date, routeId)")
}
}
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).build().also { instance = it }
).addMigrations(migration1To2).build().also { instance = it }
}
}
}
@@ -24,6 +24,16 @@ interface PhotoUploadDao {
)
fun observeVisibleForRoute(routeId: String): Flow<List<PhotoUploadEntity>>
@Query(
"""
SELECT * FROM photo_uploads
WHERE status != 'CONFIRMED'
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>>
@Query(
"""
SELECT * FROM photo_uploads
@@ -0,0 +1,39 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
object PhotoUploadFailureDetails {
fun fromApiError(error: ApiError): String {
val type = when (error.kind) {
ApiErrorKind.Network -> "błąd komunikacji"
ApiErrorKind.Server,
ApiErrorKind.RateLimited,
ApiErrorKind.Validation,
ApiErrorKind.Forbidden,
ApiErrorKind.Auth,
ApiErrorKind.Conflict,
-> "błąd serwera"
ApiErrorKind.Unknown -> "błąd aplikacji"
}
return buildString {
appendLine("Typ: $type")
appendLine("Komunikat: ${error.message}")
error.statusCode?.let { appendLine("HTTP: $it") }
error.code?.takeIf { it.isNotBlank() }?.let { appendLine("Kod: $it") }
error.responseBody
?.takeIf { it.isNotBlank() }
?.let {
appendLine("Odpowiedź serwera:")
append(it.take(MAX_RESPONSE_CHARS))
if (it.length > MAX_RESPONSE_CHARS) append("...")
}
}.trim()
}
fun local(message: String): String =
"Typ: błąd aplikacji\nKomunikat: $message"
private const val MAX_RESPONSE_CHARS = 1200
}
@@ -25,6 +25,9 @@ class PhotoUploadOutbox(
fun observeVisibleForRoute(routeId: String): Flow<List<PhotoUploadEntity>> =
dao.observeVisibleForRoute(routeId)
fun observeQueuedUploads(): Flow<List<PhotoUploadEntity>> =
dao.observeQueuedUploads()
suspend fun enqueue(routeId: String, uri: Uri, source: String, metadata: PhotoUploadMetadata): PhotoUploadEntity {
val clientRequestId = UUID.randomUUID().toString()
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
@@ -22,11 +22,12 @@ class PhotoUploadWorker(
val file = File(upload.localPath)
if (!file.exists()) {
val message = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.",
lastError = PhotoUploadFailureDetails.local(message),
attemptIncrement = 0,
)
return Result.failure()
@@ -68,7 +69,7 @@ class PhotoUploadWorker(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = error.message,
lastError = PhotoUploadFailureDetails.fromApiError(error),
attemptIncrement = 0,
)
@@ -0,0 +1,33 @@
package pl.firmatpp.kierowca.sync
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import pl.firmatpp.kierowca.data.DriverRepository
class DriverFirebaseMessagingService : FirebaseMessagingService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
scope.launch {
val repository = DriverRepository(applicationContext)
if (repository.hasToken()) {
runCatching { repository.storePushToken(token) }
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
}
}
@@ -0,0 +1,135 @@
package pl.firmatpp.kierowca.sync
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.DriverRepository
class DriverLiveSyncClient(
private val repository: DriverRepository,
private val onConnected: () -> Unit,
private val onHint: (DriverSyncHint) -> Unit,
private val client: OkHttpClient = OkHttpClient(),
private val gson: Gson = Gson(),
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val started = AtomicBoolean(false)
private var webSocket: WebSocket? = null
private var driverId: String? = null
fun start(driverId: String) {
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
this.driverId = driverId
if (!started.compareAndSet(false, true)) return
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') +
"/" + BuildConfig.REVERB_APP_KEY +
"?protocol=7&client=android&version=1.0&flash=false"
webSocket = client.newWebSocket(
Request.Builder().url(wsUrl).build(),
object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(webSocket, text)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
started.set(false)
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
started.set(false)
scheduleReconnect()
}
},
)
}
fun stop() {
started.set(false)
webSocket?.close(1000, "logout")
webSocket = null
driverId = null
}
fun close() {
stop()
scope.cancel()
}
private fun handleMessage(socket: WebSocket, text: String) {
val root = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return
val event = root.string("event") ?: return
when (event) {
"pusher:connection_established" -> {
val socketId = root.dataObject()?.string("socket_id") ?: return
subscribe(socket, socketId)
onConnected()
}
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
}
}
private fun subscribe(socket: WebSocket, socketId: String) {
val id = driverId ?: return
val channel = "private-driver-mobile.$id"
scope.launch {
runCatching {
val auth = repository.broadcastAuth(socketId, channel).auth
val payload = mapOf(
"event" to "pusher:subscribe",
"data" to mapOf(
"channel" to channel,
"auth" to auth,
),
)
socket.send(gson.toJson(payload))
}
}
}
private fun parseHint(data: JsonObject?): DriverSyncHint? {
if (data == null || data.string("type") != "driver_sync_hint") return null
return DriverSyncHint(
scope = data.string("scope") ?: return null,
date = data.string("date"),
routeId = data.string("routeId"),
version = data.string("version")?.toLongOrNull() ?: data.get("version")?.asLong ?: return null,
checksum = data.string("checksum") ?: return null,
)
}
private fun JsonObject.dataObject(): JsonObject? {
val data = get("data") ?: return null
return if (data.isJsonObject) data.asJsonObject else runCatching { JsonParser.parseString(data.asString).asJsonObject }.getOrNull()
}
private fun JsonObject.string(name: String): String? =
get(name)?.takeIf { !it.isJsonNull }?.asString
private fun scheduleReconnect() {
val id = driverId ?: return
scope.launch {
delay(5_000)
if (!started.get() && driverId == id) {
start(id)
}
}
}
}
@@ -0,0 +1,21 @@
package pl.firmatpp.kierowca.sync
import pl.firmatpp.kierowca.data.model.SyncScopeDto
data class DriverSyncHint(
val scope: String,
val date: String?,
val routeId: String?,
val version: Long,
val checksum: String,
) {
fun asScope(): SyncScopeDto =
SyncScopeDto(
scope = scope,
date = date,
routeId = routeId,
version = version,
checksum = checksum,
computedAt = "",
)
}
@@ -0,0 +1,77 @@
package pl.firmatpp.kierowca.sync
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
class DriverSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val syncRepository = DriverSyncRepository(appContext)
override suspend fun doWork(): Result =
runCatching {
val date = inputData.getString(KEY_DATE)
val routeId = inputData.getString(KEY_ROUTE_ID)
val response = syncRepository.fetchSyncState(date, routeId)
var refreshed = false
response.scopes.forEach { scope ->
if (syncRepository.shouldRefresh(scope)) {
when (scope.scope) {
DriverSyncRepository.SCOPE_ROUTES -> {
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
DriverSyncRepository.SCOPE_ROUTE_DETAIL -> {
val id = scope.routeId ?: routeId
if (!id.isNullOrBlank()) {
syncRepository.route(id)
refreshed = true
}
}
}
}
}
if (!refreshed) {
syncRepository.saveSyncStates(response)
}
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
companion object {
private const val KEY_DATE = "date"
private const val KEY_ROUTE_ID = "routeId"
fun enqueue(context: Context, date: String?, routeId: String?) {
val request = OneTimeWorkRequestBuilder<DriverSyncWorker>()
.setInputData(workDataOf(KEY_DATE to date, KEY_ROUTE_ID to routeId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
listOf("driver-sync", date ?: "-", routeId ?: "-").joinToString("-"),
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -11,6 +11,7 @@ import android.location.Location
import android.location.LocationManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
@@ -51,13 +52,17 @@ import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Phone
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
@@ -89,6 +94,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
@@ -99,6 +105,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.verticalScroll
@@ -115,6 +122,7 @@ import com.google.android.gms.common.api.Status
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.delay
import java.util.Locale
@@ -176,6 +184,7 @@ fun DriverApp(viewModel: DriverViewModel) {
onRefresh = viewModel::refreshRoutesSilently,
onDate = viewModel::selectRouteDate,
onProfile = viewModel::openProfile,
onPhotoQueue = viewModel::openPhotoQueue,
onRoute = viewModel::openRoute,
)
DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout)
@@ -188,8 +197,14 @@ fun DriverApp(viewModel: DriverViewModel) {
viewModel::deleteConfirmedUpload,
viewModel::retryPhotoUpload,
viewModel::refreshSelectedRoute,
viewModel::completeSelectedRoute,
)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
DriverScreen.PhotoQueue -> PhotoQueueScreen(
state = state,
onBack = viewModel::back,
onRetryUpload = viewModel::retryPhotoUpload,
)
}
if (state.loading && state.screen != DriverScreen.Initializing) {
@@ -205,21 +220,22 @@ private fun StartupScreen() {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.navigationBarsPadding()
.padding(32.dp),
.background(TppColors.Surface),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
BrandBannerHeader(height = 178.dp)
}
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "Firma TPP",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
)
Spacer(Modifier.height(32.dp))
{
CircularProgressIndicator(color = TppColors.Forest)
Spacer(Modifier.height(20.dp))
Text(
@@ -229,74 +245,80 @@ private fun StartupScreen() {
fontWeight = FontWeight.SemiBold,
)
}
}
}
@Composable
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
Box(
Column(
Modifier
.fillMaxSize()
.background(TppColors.Surface)
.statusBarsPadding()
.navigationBarsPadding()
.padding(24.dp),
) {
Card(
modifier = Modifier.fillMaxSize(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(4.dp),
.navigationBarsPadding(),
) {
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
BrandBannerHeader(height = 190.dp)
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 28.dp, vertical = 36.dp),
horizontalAlignment = Alignment.CenterHorizontally,
.padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp),
horizontalAlignment = Alignment.Start,
) {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "Firma TPP",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(132.dp),
Text(
"Firma TPP - Kierowca",
color = TppColors.Forest,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Spacer(Modifier.height(42.dp))
Spacer(Modifier.height(10.dp))
Text(
"Zaloguj się",
style = MaterialTheme.typography.headlineLarge,
color = TppColors.Muted,
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(76.dp))
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Spacer(Modifier.height(8.dp))
Text(
"Numer Telefonu",
color = Color.Black,
"Wpisz numer telefonu kierowcy. Kod OTP wyślemy SMS-em.",
color = TppColors.Muted,
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
)
Spacer(Modifier.height(28.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text(
"Numer telefonu",
color = TppColors.Ink,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 23.sp,
fontSize = 18.sp,
)
PhoneNumberField(
digits = phoneDigits,
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
)
Text(
"Otrzymasz kod SMS do weryfikacji.",
"Dostęp tylko dla upoważnionych kierowców.",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
fontSize = 15.sp,
)
ErrorText(state.error)
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 22.dp else 8.dp))
Button(
onClick = { onSubmit("+48$phoneDigits") },
modifier = Modifier
.fillMaxWidth()
.height(72.dp),
.height(64.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 20.dp),
@@ -305,7 +327,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
"Wyślij kod OTP",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
fontSize = 20.sp,
)
}
}
@@ -314,6 +336,46 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
}
}
@Composable
private fun BrandBannerHeader(height: Dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(height)
.background(Color(0xFFFAFCF1)),
) {
Image(
painter = painterResource(R.drawable.tpp_banner),
contentDescription = "Firma TPP",
contentScale = ContentScale.Crop,
modifier = Modifier.matchParentSize(),
)
Box(
Modifier
.matchParentSize()
.background(
Brush.horizontalGradient(
0f to Color(0xFFFAFCF1).copy(alpha = 0.56f),
0.18f to Color.Transparent,
0.82f to Color.Transparent,
1f to Color(0xFFFAFCF1).copy(alpha = 0.56f),
),
),
)
Box(
Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
0f to Color.Transparent,
0.72f to Color.Transparent,
1f to TppColors.Surface.copy(alpha = 0.88f),
),
),
)
}
}
@Composable
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
Row(
@@ -335,10 +397,11 @@ private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
}
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
BasicTextField(
value = formatPolishPhoneDigits(digits),
value = digits,
onValueChange = { onDigitsChange(it) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
visualTransformation = PolishPhoneNumberVisualTransformation,
textStyle = TextStyle(
color = TppColors.Muted,
fontSize = 28.sp,
@@ -406,6 +469,7 @@ private fun RoutesScreen(
onRefresh: () -> Unit,
onDate: (String) -> Unit,
onProfile: () -> Unit,
onPhotoQueue: () -> Unit,
onRoute: (String) -> Unit,
) {
Scaffold(
@@ -449,6 +513,8 @@ private fun RoutesScreen(
onDate = onDate,
)
}
item { OfflineStaleBanner(state) }
item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
if (state.routes.isEmpty()) {
item { EmptyState("Brak kursow na wybrany dzien") }
} else {
@@ -468,26 +534,198 @@ private fun RoutesScreen(
}
@Composable
private fun StitchHeader() {
Column(Modifier.fillMaxWidth().background(Color.White).statusBarsPadding()) {
private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: () -> Unit) {
val count = queuedPhotoUploadCount(uploads.map { it.status })
if (count <= 0) return
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.fillMaxWidth().height(104.dp).padding(horizontal = 24.dp),
Modifier.size(42.dp).background(TppColors.ContainerGreen, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
TppLogo()
Icon(Icons.Outlined.CloudUpload, contentDescription = null, tint = TppColors.Forest)
}
Text(
"W kolejce do wysłania jest $count ${photoCountLabel(count)}.",
color = TppColors.Ink,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Button(
onClick = onPhotoQueue,
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),
) {
Text("Kolejka", color = Color.White, fontWeight = FontWeight.Bold)
}
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.8f), thickness = 2.dp)
}
}
@Composable
private fun TppLogo() {
Image(
painter = painterResource(R.drawable.tpp5),
contentDescription = "TPP",
contentScale = ContentScale.Fit,
modifier = Modifier.width(142.dp).height(50.dp),
private fun PhotoQueueScreen(
state: DriverUiState,
onBack: () -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
) {
val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis }
Scaffold(
topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") },
containerColor = TppColors.Surface,
) { padding ->
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { OfflineStaleBanner(state) }
if (uploads.isEmpty()) {
item { EmptyState("Brak zdjęć oczekujących na przesłanie") }
} else {
item {
Text(
"Zdjęcia do przesłania (${uploads.size})",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = TppColors.Ink,
)
}
items(uploads, key = { it.clientRequestId }) { upload ->
PhotoQueueItem(upload, onRetryUpload)
}
}
}
}
}
@Composable
private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploadEntity) -> Unit) {
val status = upload.statusType
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
var showFailureDetails by remember { mutableStateOf(false) }
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = File(upload.localPath),
contentDescription = "Zdjęcie z kolejki",
contentScale = ContentScale.Crop,
modifier = Modifier.size(76.dp).background(TppColors.Panel, RoundedCornerShape(6.dp)),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold)
Text(
upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label,
color = if (hasFailureDetails) TppColors.Error else TppColors.Muted,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.labelMedium,
)
if (status == PhotoUploadStatus.Uploading || status == PhotoUploadStatus.Verifying) {
androidx.compose.material3.LinearProgressIndicator(
progress = { (upload.progress.coerceIn(0, 100) / 100f) },
modifier = Modifier.fillMaxWidth(),
color = TppColors.Forest,
trackColor = TppColors.Outline.copy(alpha = 0.4f),
)
}
}
if (hasFailureDetails || canRetryPhotoUpload(upload.status)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
if (hasFailureDetails) {
IconButton(
onClick = { showFailureDetails = true },
modifier = Modifier
.size(40.dp)
.background(TppColors.Surface, RoundedCornerShape(20.dp))
.border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
) {
Icon(
Icons.Outlined.Info,
contentDescription = "Pokaż szczegóły błędu",
tint = TppColors.Error,
modifier = Modifier.size(20.dp),
)
}
}
if (canRetryPhotoUpload(upload.status)) {
Button(
onClick = { onRetryUpload(upload) },
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(4.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
) {
Icon(
Icons.Outlined.Refresh,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(6.dp))
Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
}
if (showFailureDetails) {
PhotoUploadFailureDialog(
details = upload.lastError.orEmpty(),
onDismiss = { showFailureDetails = false },
)
}
}
@Composable
private fun PhotoUploadFailureDialog(details: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Dlaczego nie wysłano zdjęcia?", color = TppColors.Ink, fontWeight = FontWeight.Bold)
},
text = {
Text(
details,
color = TppColors.Ink,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()),
)
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Zamknij", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
)
}
@Composable
private fun StitchHeader() {
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
BrandBannerHeader(height = 124.dp)
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.dp)
}
}
@Composable
@@ -750,18 +988,37 @@ private fun DetailScreen(
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
onRefresh: () -> Unit,
onCompleteRoute: () -> Unit,
) {
val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showCompleteDialog by remember { mutableStateOf(false) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri
if (ok && capturedUri != null) onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
if (
ok &&
capturedUri != null &&
canLaunchCameraWithLocationPolicy(
requirePreciseLocation = state.requirePreciseLocationForPhotos,
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
)
) {
onUpload(capturedUri, "camera", cameraPhotoMetadata(context))
}
cameraUri = null
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val pendingUri = cameraUri
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
showPreciseLocationPermissionDialog = true
cameraUri = null
return@rememberLauncherForActivityResult
}
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri)
}
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
@@ -784,6 +1041,18 @@ private fun DetailScreen(
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
item { OfflineStaleBanner(state) }
item {
RouteCompletionSection(
route = route,
selectedDate = state.selectedDate,
allowRouteCompletion = state.allowRouteCompletion,
isOnline = state.isOnline,
completing = state.completingRoute,
feedback = state.feedback,
onComplete = { showCompleteDialog = true },
)
}
item {
CargoDocumentationSection(
photos = route.photos,
@@ -799,7 +1068,10 @@ private fun DetailScreen(
onCamera = {
val newUri = createCameraUri(context)
cameraUri = newUri
val missingPermissions = cameraCapturePermissions(context)
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
cameraLauncher.launch(newUri)
} else {
@@ -822,10 +1094,65 @@ private fun DetailScreen(
)
}
}
if (showCompleteDialog && route != null) {
AlertDialog(
onDismissRequest = { if (!state.completingRoute) showCompleteDialog = false },
title = { Text("Zakończyć kurs?", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = { Text("Status kursu zostanie ustawiony jako zakończony.", color = TppColors.Muted) },
confirmButton = {
TextButton(
enabled = !state.completingRoute,
onClick = {
showCompleteDialog = false
onCompleteRoute()
},
) {
Text("Zakończ kurs", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(
enabled = !state.completingRoute,
onClick = { showCompleteDialog = false },
) {
Text("Anuluj", color = TppColors.Muted)
}
},
)
}
if (showPreciseLocationPermissionDialog) {
AlertDialog(
onDismissRequest = { showPreciseLocationPermissionDialog = false },
title = { Text("Brak dokładnej lokalizacji", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = {
Text(
"Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.",
color = TppColors.Muted,
)
},
confirmButton = {
TextButton(
onClick = {
showPreciseLocationPermissionDialog = false
openAppSettings(context)
},
) {
Text("Przejdź do ustawień", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showPreciseLocationPermissionDialog = false }) {
Text("Anuluj", color = TppColors.Muted)
}
},
)
}
}
@Composable
private fun DetailHeader(onBack: () -> Unit) {
private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy") {
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
Row(
Modifier.fillMaxWidth().height(64.dp).padding(start = 8.dp, end = 16.dp),
@@ -835,7 +1162,7 @@ private fun DetailHeader(onBack: () -> Unit) {
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink)
}
Text(
"Szczegóły Trasy",
title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = TppColors.Ink,
@@ -846,6 +1173,93 @@ private fun DetailHeader(onBack: () -> Unit) {
}
}
@Composable
private fun RouteCompletionSection(
route: DriverRouteDto,
selectedDate: String,
allowRouteCompletion: Boolean,
isOnline: Boolean,
completing: Boolean,
feedback: String?,
onComplete: () -> Unit,
) {
if (!allowRouteCompletion) return
if (route.status == "ZAKOŃCZONA") {
if (!feedback.isNullOrBlank()) {
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFEAF7EF)),
border = BorderStroke(1.dp, Color(0xFF9BD1AD)),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = TppColors.Forest)
Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
}
}
return
}
if (!canCompleteRouteFromDriverApp(route, selectedDate)) return
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, TppColors.Outline),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Button(
onClick = onComplete,
enabled = isOnline && !completing,
modifier = Modifier.fillMaxWidth().height(58.dp),
colors = ButtonDefaults.buttonColors(
containerColor = TppColors.Forest,
disabledContainerColor = TppColors.Outline,
),
shape = RoundedCornerShape(8.dp),
) {
if (completing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White)
}
Spacer(Modifier.width(10.dp))
Text(
if (completing) "Kończę kurs..." else "Zakończ kurs",
color = Color.White,
fontWeight = FontWeight.Bold,
)
}
if (!isOnline) {
Text(
"Kurs można zakończyć tylko po połączeniu z serwerem.",
color = TppColors.Muted,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
private fun photoCountLabel(count: Int): String =
when {
count == 1 -> "zdjęcie"
count in 2..4 -> "zdjęcia"
else -> "zdjęć"
}
@Composable
private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status)
@@ -1076,6 +1490,7 @@ private fun PhotoGrid(
is PhotoGridItem.Upload -> PendingPhotoTile(
upload = item.upload,
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
onRetry = onRetryUpload,
modifier = Modifier.weight(1f),
@@ -1182,18 +1597,27 @@ private fun PhotoTile(
private fun PendingPhotoTile(
upload: PhotoUploadEntity,
isDeleting: Boolean,
onPhoto: (PhotoUploadEntity) -> Unit,
onDelete: (PhotoUploadEntity) -> Unit,
onRetry: (PhotoUploadEntity) -> Unit,
modifier: Modifier = Modifier,
) {
val status = upload.statusType
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
var showFailureDetails by remember { mutableStateOf(false) }
if (showFailureDetails) {
PhotoUploadFailureDialog(
details = upload.lastError.orEmpty(),
onDismiss = { showFailureDetails = false },
)
}
Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) {
AsyncImage(
model = File(upload.localPath),
contentDescription = "Zdjęcie oczekujące na zapis",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().clickable { onPhoto(upload) },
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f)))
if (isDeleting) {
@@ -1223,13 +1647,51 @@ private fun PendingPhotoTile(
)
}
}
if (hasFailureDetails) {
IconButton(
onClick = { showFailureDetails = true },
enabled = !isDeleting,
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.size(40.dp)
.background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
.border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
) {
Icon(
Icons.Outlined.Info,
contentDescription = "Pokaż szczegóły błędu wysłania",
tint = TppColors.Error,
modifier = Modifier.size(20.dp),
)
}
}
if (canRetryPhotoUpload(upload.status)) {
IconButton(
onClick = { onRetry(upload) },
enabled = !isDeleting,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(40.dp)
.background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
.border(1.dp, TppColors.Forest.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
) {
Icon(
Icons.Outlined.Refresh,
contentDescription = "Ponów wysłanie zdjęcia",
tint = TppColors.Forest,
modifier = Modifier.size(20.dp),
)
}
}
Column(
Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Color.White.copy(alpha = 0.94f)).padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label,
color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Ink,
upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label,
color = if (hasFailureDetails) TppColors.Error else TppColors.Ink,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelMedium,
@@ -1242,16 +1704,6 @@ private fun PendingPhotoTile(
trackColor = TppColors.Outline.copy(alpha = 0.4f),
)
}
if (status == PhotoUploadStatus.FailedRetryable) {
Button(
onClick = { onRetry(upload) },
modifier = Modifier.fillMaxWidth().height(40.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(4.dp),
) {
Text("Ponów", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
@@ -1334,6 +1786,36 @@ private fun ErrorText(error: String?) {
if (!error.isNullOrBlank()) Text(error, color = TppColors.Error, modifier = Modifier.padding(vertical = 8.dp))
}
@Composable
private fun OfflineStaleBanner(state: DriverUiState) {
if (state.isOnline && !state.isStale) return
val syncLabel = state.lastSuccessfulSyncAtEpochMillis?.let(::formatSyncTime) ?: "brak zapisanej synchronizacji"
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF7E6)),
border = BorderStroke(1.dp, Color(0xFFE6B85C)),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = if (state.isOnline) {
"Dane mogą być nieaktualne. Ostatnia synchronizacja: $syncLabel."
} else {
"Brak połączenia z serwerem. Dane z $syncLabel mogą być nieaktualne."
},
color = Color(0xFF5F4200),
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(12.dp),
)
}
}
private fun formatSyncTime(epochMillis: Long): String =
runCatching {
DateTimeFormatter.ofPattern("HH:mm", Locale("pl", "PL"))
.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault()))
}.getOrDefault("nieznana godzina")
private fun polishPhoneDigits(value: String): String {
val digits = value.filter(Char::isDigit)
val withoutCountryCode = if (digits.length > 9 && digits.startsWith("48")) digits.drop(2) else digits
@@ -1341,9 +1823,6 @@ private fun polishPhoneDigits(value: String): String {
return withoutCountryCode.take(9)
}
private fun formatPolishPhoneDigits(value: String): String =
value.chunked(3).joinToString(" ")
private fun createCameraUri(context: Context): Uri {
val dir = File(context.cacheDir, "camera").apply { mkdirs() }
val file = File.createTempFile("ladunek-", ".jpg", dir)
@@ -1370,11 +1849,15 @@ private fun openNavigation(context: Context, point: NavigationPointDto) {
}
}
private fun cameraCapturePermissions(context: Context): Array<String> = buildList {
private fun cameraCapturePermissions(context: Context, requirePreciseLocation: Boolean): Array<String> = buildList {
if (!hasPermission(context, Manifest.permission.CAMERA)) {
add(Manifest.permission.CAMERA)
}
if (!hasLocationPermission(context)) {
if (requirePreciseLocation) {
if (!hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)) {
add(Manifest.permission.ACCESS_FINE_LOCATION)
}
} else if (!hasLocationPermission(context)) {
add(Manifest.permission.ACCESS_FINE_LOCATION)
add(Manifest.permission.ACCESS_COARSE_LOCATION)
}
@@ -1398,6 +1881,14 @@ private fun hasLocationPermission(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private fun openAppSettings(context: Context) {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
context.startActivity(intent)
}
private fun bestLastKnownLocation(context: Context): Location? {
if (!hasLocationPermission(context)) return null
@@ -1,17 +1,32 @@
package pl.firmatpp.kierowca.ui
import java.io.File
import java.time.LocalDate
import java.time.OffsetDateTime
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
fun canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
fun queuedPhotoUploadCount(statuses: List<String>): Int =
statuses.count { status -> status != "CONFIRMED" && status != "CANCELLED" }
fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Long): Long =
parseIsoOffsetEpochMillis(createdAt)
?: parseIsoOffsetEpochMillis(takenAt)
@@ -20,6 +35,37 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean =
status == "FAILED_RETRYABLE"
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
if (upload.localPath.isBlank()) return null
return RoutePhotoDto(
id = upload.clientRequestId,
routeId = upload.routeId,
clientRequestId = upload.clientRequestId,
contentSha256 = upload.contentSha256,
source = upload.source,
mimeType = upload.mimeType,
size = upload.size,
url = File(upload.localPath).toURI().toString(),
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
createdAt = null,
canDelete = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null,
deleteUntil = null,
)
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
@@ -4,6 +4,7 @@ import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -11,15 +12,21 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDate
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo }
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue }
data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing,
@@ -35,23 +42,57 @@ data class DriverUiState(
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
val autoRefreshSeconds: Int = 60,
val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false,
val imageAuthHeader: String? = null,
val isOnline: Boolean = true,
val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null,
val feedback: String? = null,
val error: String? = null,
)
class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DriverRepository(application)
private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val liveSyncClient = DriverLiveSyncClient(
repository = repository,
onConnected = { viewModelScope.launch { checkRemoteSyncState() } },
onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } },
)
private var photoUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state
init {
viewModelScope.launch {
photoUploadOutbox.observeQueuedUploads().collect { uploads ->
_state.update { it.copy(queuedPhotoUploads = uploads) }
}
}
viewModelScope.launch {
networkMonitor.isOnline.collect { online ->
_state.update {
it.copy(
isOnline = online,
isStale = if (!online && it.lastSuccessfulSyncAtEpochMillis != null) true else it.isStale,
)
}
if (online && repository.hasToken()) {
refreshCurrentScopeFromSyncState()
}
}
}
viewModelScope.launch {
if (repository.hasToken()) {
refreshRoutes()
@@ -98,10 +139,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) }
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching {
val response = repository.bootstrap(date)
val cached = syncRepository.bootstrap(date)
val response = cached.value
val settings = response.driverAppSettings
_state.update {
it.copy(
@@ -113,15 +155,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId)
registerPushTokenIfAvailable(driverId)
}
}.onFailure { throwable ->
_state.update {
val apiError = ApiErrorMapper.map(throwable)
it.copy(
screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Phone else it.screen,
error = throwable.message ?: "Wystapil blad.",
screen = when {
apiError.kind == ApiErrorKind.Auth -> DriverScreen.Phone
it.screen == DriverScreen.Initializing -> DriverScreen.Routes
else -> it.screen
},
isStale = it.lastSuccessfulSyncAtEpochMillis != null,
error = apiError.message,
)
}
}
@@ -133,9 +190,19 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
fun openRoute(routeId: String) = runLoading {
val response = repository.route(routeId)
val cached = syncRepository.route(routeId)
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) }
_state.update {
it.copy(
screen = DriverScreen.Detail,
selectedRoute = response.route,
feedback = null,
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
observePhotoUploads(routeId)
}
@@ -143,15 +210,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val routeId = _state.value.selectedRoute?.id ?: return
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null) }
_state.update { it.copy(refreshing = true, feedback = null, error = null) }
runCatching { repository.route(routeId) }
.onSuccess { response ->
runCatching { syncRepository.route(routeId) }
.onSuccess { cached ->
val response = cached.value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { route -> if (route.id == routeId) response.route else route },
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
)
}
@@ -168,6 +238,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
}
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val route = _state.value.selectedRoute ?: return
viewModelScope.launch {
@@ -194,7 +268,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + photo.id, error = null) }
runCatching {
repository.deletePhoto(photo.id)
val response = repository.route(route.id)
val response = syncRepository.route(route.id).value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
@@ -219,7 +293,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching {
repository.deletePhoto(serverPhotoId)
photoUploadOutbox.discard(upload.clientRequestId)
val response = repository.route(route.id)
val response = syncRepository.route(route.id).value
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
@@ -235,6 +309,37 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return
if (_state.value.completingRoute || !_state.value.isOnline) return
viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
runCatching {
val completed = repository.completeRoute(route.id).route
syncRepository.cacheConfirmedRoute(completed, _state.value.selectedDate)
val refreshed = runCatching { syncRepository.route(route.id) }
.getOrNull()
?.takeUnless { it.stale }
?.value
?.route
?: completed
photoUploadOutbox.discardConfirmedServerPhotos(refreshed)
_state.update {
it.copy(
selectedRoute = refreshed,
routes = it.routes.map { item -> if (item.id == route.id) refreshed else item },
feedback = "Kurs zakończony",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(completingRoute = false) }
}
}
fun openPhoto(photo: RoutePhotoDto) {
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
}
@@ -243,9 +348,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update {
when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Detail -> {
photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList())
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
}
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
@@ -256,7 +362,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun logout() = runLoading {
photoUploadsJob?.cancel()
liveSyncClient.stop()
repository.logout()
syncRepository.clearCache()
pushTokenRegisteredForDriverId = null
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
}
@@ -279,4 +388,71 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
}
private fun refreshCurrentScopeFromSyncState() {
val snapshot = _state.value
if (snapshot.screen == DriverScreen.Routes) {
refreshRoutesSilently()
return
}
if (snapshot.screen == DriverScreen.Detail) {
refreshSelectedRoute()
}
}
private suspend fun checkRemoteSyncState() {
val snapshot = _state.value
val routeId = snapshot.selectedRoute?.id
val remote = runCatching { syncRepository.fetchSyncState(snapshot.selectedDate, routeId) }.getOrNull() ?: return
val needsRefresh = remote.scopes.any { syncRepository.shouldRefresh(it) }
if (needsRefresh) {
refreshCurrentScopeFromSyncState()
} else {
syncRepository.saveSyncStates(remote)
}
}
private suspend fun handleSyncHint(hint: DriverSyncHint) {
if (!syncRepository.shouldRefresh(hint.asScope())) return
val snapshot = _state.value
when (hint.scope) {
DriverSyncRepository.SCOPE_ROUTES -> {
if (hint.date == null || hint.date == snapshot.selectedDate) {
refreshRoutesSilently()
} else {
DriverSyncWorker.enqueue(getApplication(), hint.date, null)
}
}
DriverSyncRepository.SCOPE_ROUTE_DETAIL -> {
if (hint.routeId == snapshot.selectedRoute?.id) {
refreshSelectedRoute()
} else {
DriverSyncWorker.enqueue(getApplication(), hint.date, hint.routeId)
}
}
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
}
}
private fun registerPushTokenIfAvailable(driverId: String) {
if (pushTokenRegisteredForDriverId == driverId) return
runCatching {
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
viewModelScope.launch {
runCatching {
repository.storePushToken(token)
pushTokenRegisteredForDriverId = driverId
}
}
}
}
}
override fun onCleared() {
liveSyncClient.close()
super.onCleared()
}
}
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
object PolishPhoneNumberVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val rawDigits = text.text.filter(Char::isDigit).take(9)
val formatted = rawDigits.chunked(3).joinToString(" ")
return TransformedText(
AnnotatedString(formatted),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val safeOffset = offset.coerceIn(0, rawDigits.length)
val spacesBeforeOffset = when {
safeOffset <= 3 -> 0
safeOffset <= 6 -> 1
else -> 2
}
return (safeOffset + spacesBeforeOffset).coerceAtMost(formatted.length)
}
override fun transformedToOriginal(offset: Int): Int {
val safeOffset = offset.coerceIn(0, formatted.length)
return formatted
.take(safeOffset)
.count(Char::isDigit)
.coerceAtMost(rawDigits.length)
}
},
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F7FAF8" />
<solid android:color="#07172B" />
</shape>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0B192E</color>
</resources>
@@ -39,4 +39,16 @@ class ApiErrorMapperTest {
assertTrue(error.retryable)
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
}
@Test
fun keepsRawServerResponseForTroubleshooting() {
val body = """{"code":"PHOTO_SERVER_UNAVAILABLE","message":"Storage timeout","retryable":true}"""
val error = ApiErrorMapper.mapHttpStatus(503, body)
assertEquals(ApiErrorKind.Server, error.kind)
assertEquals(503, error.statusCode)
assertEquals("PHOTO_SERVER_UNAVAILABLE", error.code)
assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody)
}
}
@@ -0,0 +1,44 @@
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.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
class PhotoUploadFailureDetailsTest {
@Test
fun describesServerFailureWithStatusCodeBackendCodeAndResponseBody() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Server,
message = "Storage timeout",
retryable = true,
code = "PHOTO_STORAGE_FAILED",
statusCode = 503,
responseBody = """{"code":"PHOTO_STORAGE_FAILED","message":"Storage timeout"}""",
),
)
assertTrue(details.contains("Typ: błąd serwera"))
assertTrue(details.contains("HTTP: 503"))
assertTrue(details.contains("Kod: PHOTO_STORAGE_FAILED"))
assertTrue(details.contains("Odpowiedź serwera:"))
assertTrue(details.contains("Storage timeout"))
}
@Test
fun describesNetworkFailureWithoutHttpStatus() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem.",
retryable = true,
),
)
assertTrue(details.contains("Typ: błąd komunikacji"))
assertTrue(details.contains("Nie udało się połączyć z serwerem."))
assertFalse(details.contains("HTTP:"))
}
}
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30")
@@ -22,6 +24,16 @@ class DriverUiRulesTest {
assertFalse(canManageRoutePhotos("not-a-date", today))
}
@Test
fun allowsRouteCompletionOnlyForTodayPlannedOrInProgressRoutes() {
assertTrue(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-30", today))
assertTrue(canCompleteRouteFromDriverApp(route(status = "W TRAKCIE"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAKOŃCZONA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "NIEROZPOZNANA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-07-01", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0))
@@ -38,6 +50,25 @@ class DriverUiRulesTest {
assertEquals(3, visiblePhotoAttachmentCount(serverPhotoCount = 1, localUploadCount = 2))
}
@Test
fun countsOnlyUnconfirmedNonCancelledUploadsInQueue() {
assertEquals(0, queuedPhotoUploadCount(emptyList()))
assertEquals(
4,
queuedPhotoUploadCount(
listOf(
"PENDING",
"UPLOADING",
"VERIFYING",
"FAILED_RETRYABLE",
"CONFIRMED",
"CANCELLED",
),
),
)
assertEquals(1, queuedPhotoUploadCount(listOf("FAILED_PERMANENT")))
}
@Test
fun usesCreatedAtBeforeTakenAtForNewestFirstPhotoSorting() {
val fallback = 100L
@@ -68,4 +99,78 @@ class DriverUiRulesTest {
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
}
@Test
fun allowsRetryActionOnlyForRetryableFailedUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING"))
assertFalse(canRetryPhotoUpload("CONFIRMED"))
assertFalse(canRetryPhotoUpload("FAILED_PERMANENT"))
assertFalse(canRetryPhotoUpload("CANCELLED"))
}
@Test
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
assertTrue(canShowPhotoUploadFailureDetails("FAILED_PERMANENT", "Typ: błąd aplikacji"))
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", null))
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", ""))
assertFalse(canShowPhotoUploadFailureDetails("UPLOADING", "Typ: błąd serwera"))
assertFalse(canShowPhotoUploadFailureDetails("CONFIRMED", "Typ: błąd serwera"))
}
@Test
fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() {
val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))
requireNotNull(photo)
assertEquals("local-upload-123", photo.id)
assertEquals("10", photo.routeId)
assertEquals("file:/tmp/photo.jpg", photo.url)
assertEquals("2026-07-01T08:00:00Z", photo.takenAt)
assertEquals(50.1, photo.latitude ?: 0.0, 0.0)
assertEquals(true, photo.canDelete)
}
@Test
fun preciseLocationRequirementAllowsCameraOnlyWithFineLocation() {
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = false, hasFineLocation = false))
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = true))
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
}
private fun route(status: String): DriverRouteDto =
DriverRouteDto(
id = "1",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
distanceKm = 1.0,
notes = null,
truck = null,
)
private fun upload(status: String, serverPhotoId: String? = null): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = "local-upload-123",
routeId = "10",
localPath = "/tmp/photo.jpg",
source = "camera",
takenAt = "2026-07-01T08:00:00Z",
latitude = 50.1,
longitude = 19.9,
locationAccuracyMeters = 8.0,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
status = status,
serverPhotoId = serverPhotoId,
)
}
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Test
class PolishPhoneNumberVisualTransformationTest {
@Test
fun formatsPhoneDigitsWithoutChangingUnderlyingTextOffsets() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("1234"))
assertEquals("123 4", transformed.text.text)
assertEquals(5, transformed.offsetMapping.originalToTransformed(4))
assertEquals(4, transformed.offsetMapping.transformedToOriginal(5))
}
@Test
fun mapsOffsetsAcrossBothInsertedSpaces() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("123456789"))
assertEquals("123 456 789", transformed.text.text)
assertEquals(7, transformed.offsetMapping.originalToTransformed(6))
assertEquals(11, transformed.offsetMapping.originalToTransformed(9))
assertEquals(6, transformed.offsetMapping.transformedToOriginal(7))
assertEquals(9, transformed.offsetMapping.transformedToOriginal(11))
}
}
+1
View File
@@ -2,4 +2,5 @@ plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) apply false
}
+5
View File
@@ -18,6 +18,8 @@ ksp = "2.1.10-1.0.31"
playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0"
junit = "4.13.2"
firebaseBom = "33.7.0"
googleServices = "4.4.2"
[libraries]
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -51,9 +53,12 @@ play-services-auth = { group = "com.google.android.gms", name = "play-services-a
play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", 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-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }