Compare commits

..
3 Commits
Author SHA1 Message Date
admin 06557e77b8 Dodaj usuwanie świeżo zapisanych zdjęć 2026-07-01 02:43:52 +02:00
admin a08fe31d3d Popraw obsługę galerii zdjęć kierowcy 2026-07-01 02:37:43 +02:00
admin 060896d12b Dodaj pewny outbox uploadu zdjęć 2026-07-01 02:28:44 +02:00
20 changed files with 1047 additions and 50 deletions
+7 -2
View File
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
} }
android { android {
@@ -12,8 +13,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 14 versionCode = 17
versionName = "1.0.13" versionName = "1.0.16"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"") buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
@@ -61,6 +62,8 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
implementation(libs.camera.camera2) implementation(libs.camera.camera2)
implementation(libs.camera.core) implementation(libs.camera.core)
@@ -85,4 +88,6 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
ksp(libs.androidx.room.compiler)
} }
@@ -0,0 +1,90 @@
package pl.firmatpp.kierowca.data
import java.io.IOException
import retrofit2.HttpException
enum class ApiErrorKind {
Network,
Auth,
Forbidden,
Validation,
Conflict,
RateLimited,
Server,
Unknown,
}
data class ApiError(
val kind: ApiErrorKind,
val message: String,
val retryable: Boolean,
val code: String? = null,
val statusCode: Int? = null,
)
object ApiErrorMapper {
fun map(throwable: Throwable): ApiError =
when (throwable) {
is IOException -> ApiError(
kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.",
retryable = true,
)
is HttpException -> mapHttpException(throwable)
else -> ApiError(
kind = ApiErrorKind.Unknown,
message = throwable.message ?: "Wystąpił błąd. Operacja nie została potwierdzona.",
retryable = false,
)
}
fun mapHttpStatus(statusCode: Int, body: String?): ApiError {
val code = body?.let { """"code"\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() }
return mapProblem(
statusCode = statusCode,
code = code,
message = message,
retryable = retryable,
)
}
fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError {
val kind = when (statusCode) {
401 -> ApiErrorKind.Auth
403 -> ApiErrorKind.Forbidden
400, 422 -> ApiErrorKind.Validation
409 -> ApiErrorKind.Conflict
429 -> ApiErrorKind.RateLimited
in 500..599 -> ApiErrorKind.Server
else -> ApiErrorKind.Unknown
}
val defaultRetryable = statusCode == 429 || statusCode in 500..599
val resolvedRetryable = retryable ?: defaultRetryable
val resolvedMessage = message?.takeIf { it.isNotBlank() } ?: when (kind) {
ApiErrorKind.Auth -> "Sesja wygasła. Zaloguj się ponownie."
ApiErrorKind.Forbidden -> "Brak uprawnień do tej operacji."
ApiErrorKind.Validation -> "Serwer odrzucił operację. Nie została zapisana."
ApiErrorKind.Conflict -> "Operacja jest w konflikcie z aktualnym stanem danych."
ApiErrorKind.RateLimited -> "Za dużo prób. Aplikacja spróbuje ponownie później."
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.Unknown -> "Wystąpił błąd. Operacja nie została potwierdzona."
}
return ApiError(
kind = kind,
message = resolvedMessage,
retryable = resolvedRetryable,
code = code,
statusCode = statusCode,
)
}
private fun mapHttpException(exception: HttpException): ApiError {
val body = runCatching { exception.response()?.errorBody()?.string() }.getOrNull()
return mapHttpStatus(exception.code(), body)
}
}
@@ -2,8 +2,12 @@ package pl.firmatpp.kierowca.data
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import pl.firmatpp.kierowca.data.api.ApiFactory import pl.firmatpp.kierowca.data.api.ApiFactory
import pl.firmatpp.kierowca.data.api.MobileDriverApi import pl.firmatpp.kierowca.data.api.MobileDriverApi
@@ -14,6 +18,7 @@ import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.RequestOtpBody import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverRepository( class DriverRepository(
private val context: Context, private val context: Context,
@@ -46,6 +51,8 @@ class DriverRepository(
val resolver = context.contentResolver val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "image/jpeg" val mimeType = resolver.getType(uri) ?: "image/jpeg"
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Nie mozna odczytac zdjecia.") val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: error("Nie mozna odczytac zdjecia.")
val clientRequestId = UUID.randomUUID().toString()
val contentSha256 = sha256(bytes)
val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body) val photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body)
val sourceBody = source.toRequestBody("text/plain".toMediaTypeOrNull()) val sourceBody = source.toRequestBody("text/plain".toMediaTypeOrNull())
@@ -53,8 +60,11 @@ class DriverRepository(
return api.uploadPhoto( return api.uploadPhoto(
authHeader(requireToken()), authHeader(requireToken()),
clientRequestId,
routeId, routeId,
photo, photo,
clientRequestId.toPlainTextBody(),
contentSha256.toPlainTextBody(),
sourceBody, sourceBody,
metadataParts["takenAt"], metadataParts["takenAt"],
metadataParts["latitude"], metadataParts["latitude"],
@@ -63,6 +73,32 @@ class DriverRepository(
) )
} }
suspend fun uploadQueuedPhoto(upload: PhotoUploadEntity): PhotoUploadResponse {
val file = File(upload.localPath)
val body = file.readBytes().toRequestBody(upload.mimeType.toMediaTypeOrNull())
val photo = MultipartBody.Part.createFormData("photo", file.name, body)
val metadataParts = PhotoUploadMetadata(
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
).toMultipartTextParts()
return api.uploadPhoto(
authHeader(requireToken()),
upload.clientRequestId,
upload.routeId,
photo,
upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
metadataParts["locationAccuracyMeters"],
)
}
suspend fun deletePhoto(photoId: String) { suspend fun deletePhoto(photoId: String) {
api.deletePhoto(authHeader(requireToken()), photoId) api.deletePhoto(authHeader(requireToken()), photoId)
} }
@@ -82,4 +118,11 @@ class DriverRepository(
private suspend fun requireToken(): String = tokenStore.read() ?: error("Brak aktywnej sesji.") private suspend fun requireToken(): String = tokenStore.read() ?: error("Brak aktywnej sesji.")
private fun authHeader(token: String): String = "Bearer $token" private fun authHeader(token: String): String = "Bearer $token"
private fun String.toPlainTextBody(): RequestBody = toRequestBody("text/plain".toMediaTypeOrNull())
private fun sha256(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(bytes)
.joinToString("") { "%02x".format(it) }
} }
@@ -45,8 +45,11 @@ interface MobileDriverApi {
@POST("mobile/driver/routes/{routeId}/photos") @POST("mobile/driver/routes/{routeId}/photos")
suspend fun uploadPhoto( suspend fun uploadPhoto(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Path("routeId") routeId: String, @Path("routeId") routeId: String,
@Part photo: MultipartBody.Part, @Part photo: MultipartBody.Part,
@Part("clientRequestId") clientRequestId: RequestBody,
@Part("contentSha256") contentSha256: RequestBody,
@Part("source") source: RequestBody, @Part("source") source: RequestBody,
@Part("takenAt") takenAt: RequestBody?, @Part("takenAt") takenAt: RequestBody?,
@Part("latitude") latitude: RequestBody?, @Part("latitude") latitude: RequestBody?,
@@ -106,6 +106,8 @@ data class NavigationPointDto(
data class RoutePhotoDto( data class RoutePhotoDto(
val id: String, val id: String,
val routeId: String, val routeId: String,
val clientRequestId: String? = null,
val contentSha256: String? = null,
val source: String, val source: String,
val mimeType: String?, val mimeType: String?,
val size: Long, val size: Long,
@@ -121,4 +123,12 @@ data class RoutePhotoDto(
data class PhotoUploadResponse( data class PhotoUploadResponse(
val photo: RoutePhotoDto, val photo: RoutePhotoDto,
val receipt: PhotoUploadReceiptDto,
)
data class PhotoUploadReceiptDto(
val clientRequestId: String,
val serverPhotoId: String,
val contentSha256: String,
val storedAt: String?,
) )
@@ -0,0 +1,29 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(
entities = [PhotoUploadEntity::class],
version = 1,
exportSchema = false,
)
abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao
companion object {
@Volatile
private var instance: DriverDatabase? = null
fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
DriverDatabase::class.java,
"driver-local-outbox.db",
).build().also { instance = it }
}
}
}
@@ -0,0 +1,76 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface PhotoUploadDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(upload: PhotoUploadEntity)
@Query("SELECT * FROM photo_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): PhotoUploadEntity?
@Query(
"""
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeVisibleForRoute(routeId: String): Flow<List<PhotoUploadEntity>>
@Query(
"""
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status = 'CONFIRMED'
AND clientRequestId IN (:clientRequestIds)
""",
)
suspend fun confirmedForServerPhotos(routeId: String, clientRequestIds: List<String>): List<PhotoUploadEntity>
@Query(
"""
UPDATE photo_uploads
SET status = :status,
progress = :progress,
lastError = :lastError,
attemptCount = attemptCount + :attemptIncrement,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun updateStatus(
clientRequestId: String,
status: String,
progress: Int,
lastError: String?,
attemptIncrement: Int,
updatedAt: Long = System.currentTimeMillis(),
)
@Query(
"""
UPDATE photo_uploads
SET status = 'CONFIRMED',
progress = 100,
lastError = NULL,
serverPhotoId = :serverPhotoId,
updatedAtEpochMillis = :updatedAt
WHERE clientRequestId = :clientRequestId
""",
)
suspend fun markConfirmed(
clientRequestId: String,
serverPhotoId: String,
updatedAt: Long = System.currentTimeMillis(),
)
@Query("DELETE FROM photo_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
}
@@ -0,0 +1,36 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "photo_uploads",
indices = [
Index(value = ["routeId"]),
Index(value = ["status"]),
],
)
data class PhotoUploadEntity(
@PrimaryKey val clientRequestId: String,
val routeId: String,
val localPath: String,
val source: String,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val mimeType: String,
val size: Long,
val contentSha256: String,
val status: String = PhotoUploadStatus.Pending.storageValue,
val progress: Int = 0,
val attemptCount: Int = 0,
val lastError: String? = null,
val serverPhotoId: String? = null,
val createdAtEpochMillis: Long = System.currentTimeMillis(),
val updatedAtEpochMillis: Long = System.currentTimeMillis(),
) {
val statusType: PhotoUploadStatus
get() = PhotoUploadStatus.fromStorage(status)
}
@@ -0,0 +1,119 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import android.net.Uri
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.Flow
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverRouteDto
class PhotoUploadOutbox(
private val context: Context,
private val dao: PhotoUploadDao = DriverDatabase.get(context).photoUploadDao(),
private val workManager: WorkManager = WorkManager.getInstance(context),
) {
fun observeVisibleForRoute(routeId: String): Flow<List<PhotoUploadEntity>> =
dao.observeVisibleForRoute(routeId)
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"
val extension = when (mimeType) {
"image/png" -> "png"
"image/webp" -> "webp"
else -> "jpg"
}
val uploadDir = File(context.filesDir, "photo-upload-outbox").apply { mkdirs() }
val localFile = File(uploadDir, "$clientRequestId.$extension")
val sha256 = copyAndHash(uri, localFile)
val upload = PhotoUploadEntity(
clientRequestId = clientRequestId,
routeId = routeId,
localPath = localFile.absolutePath,
source = source,
takenAt = metadata.takenAt,
latitude = metadata.latitude,
longitude = metadata.longitude,
locationAccuracyMeters = metadata.locationAccuracyMeters,
mimeType = mimeType,
size = localFile.length(),
contentSha256 = sha256,
)
dao.upsert(upload)
enqueueWorker(clientRequestId)
return upload
}
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)
}
suspend fun discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct()
if (confirmedRequestIds.isEmpty()) return
dao.confirmedForServerPhotos(route.id, confirmedRequestIds).forEach { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<PhotoUploadWorker>()
.setInputData(workDataOf(PhotoUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
PhotoUploadWorker.uniqueWorkName(clientRequestId),
ExistingWorkPolicy.REPLACE,
request,
)
}
private fun copyAndHash(uri: Uri, target: File): String {
val digest = MessageDigest.getInstance("SHA-256")
context.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "Nie można odczytać zdjęcia." }
target.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read == -1) break
digest.update(buffer, 0, read)
output.write(buffer, 0, read)
}
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,9 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.model.PhotoUploadReceiptDto
object PhotoUploadReceiptVerifier {
fun matches(upload: PhotoUploadEntity, receipt: PhotoUploadReceiptDto): Boolean =
receipt.clientRequestId == upload.clientRequestId &&
receipt.contentSha256.lowercase() == upload.contentSha256.lowercase()
}
@@ -0,0 +1,20 @@
package pl.firmatpp.kierowca.data.upload
enum class PhotoUploadStatus(
val storageValue: String,
val label: String,
val isStoredOnServer: Boolean,
) {
Pending("PENDING", "Czeka na wysłanie", false),
Uploading("UPLOADING", "Wysyłam zdjęcie", false),
Verifying("VERIFYING", "Sprawdzam zapis", false),
Confirmed("CONFIRMED", "Zapisane", true),
FailedRetryable("FAILED_RETRYABLE", "Nie wysłano, ponów", false),
FailedPermanent("FAILED_PERMANENT", "Zdjęcie nie zostało zapisane", false),
Cancelled("CANCELLED", "Anulowano", false);
companion object {
fun fromStorage(value: String): PhotoUploadStatus =
entries.firstOrNull { it.storageValue == value } ?: Pending
}
}
@@ -0,0 +1,85 @@
package pl.firmatpp.kierowca.data.upload
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.io.File
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
class PhotoUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).photoUploadDao()
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val clientRequestId = inputData.getString(KEY_CLIENT_REQUEST_ID) ?: return Result.failure()
val upload = dao.find(clientRequestId) ?: return Result.failure()
val file = File(upload.localPath)
if (!file.exists()) {
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.",
attemptIncrement = 0,
)
return Result.failure()
}
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Uploading.storageValue,
progress = 10,
lastError = null,
attemptIncrement = 1,
)
setProgress(workDataOf(KEY_PROGRESS to 10))
return runCatching {
setProgress(workDataOf(KEY_PROGRESS to 70))
val response = repository.uploadQueuedPhoto(upload)
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.Verifying.storageValue,
progress = 90,
lastError = null,
attemptIncrement = 0,
)
setProgress(workDataOf(KEY_PROGRESS to 90))
val receipt = response.receipt
if (!PhotoUploadReceiptVerifier.matches(upload, receipt)) {
error("Serwer nie potwierdził zgodności zdjęcia. Zdjęcie nie zostało zapisane.")
}
dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
Result.success()
}.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable)
val status = if (error.retryable) PhotoUploadStatus.FailedRetryable else PhotoUploadStatus.FailedPermanent
dao.updateStatus(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = error.message,
attemptIncrement = 0,
)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.failure()
}
}
companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress"
fun uniqueWorkName(clientRequestId: String): String = "photo-upload-$clientRequestId"
}
}
@@ -41,9 +41,6 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -65,6 +62,7 @@ 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
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -78,6 +76,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -117,7 +116,6 @@ import java.io.File
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.ceil
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.Locale import java.util.Locale
import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.R
@@ -125,6 +123,8 @@ 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.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.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
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.ui.theme.TppColors import pl.firmatpp.kierowca.ui.theme.TppColors
@@ -179,7 +179,16 @@ fun DriverApp(viewModel: DriverViewModel) {
onRoute = viewModel::openRoute, onRoute = viewModel::openRoute,
) )
DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout) DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout)
DriverScreen.Detail -> DetailScreen(state, viewModel::back, viewModel::uploadPhoto, viewModel::openPhoto, viewModel::deletePhoto, viewModel::refreshSelectedRoute) DriverScreen.Detail -> DetailScreen(
state,
viewModel::back,
viewModel::uploadPhoto,
viewModel::openPhoto,
viewModel::deletePhoto,
viewModel::deleteConfirmedUpload,
viewModel::retryPhotoUpload,
viewModel::refreshSelectedRoute,
)
DriverScreen.Photo -> PhotoScreen(state, viewModel::back) DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
} }
@@ -738,6 +747,8 @@ private fun DetailScreen(
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -776,11 +787,15 @@ private fun DetailScreen(
item { item {
CargoDocumentationSection( CargoDocumentationSection(
photos = route.photos, photos = route.photos,
uploads = state.photoUploads,
deletingPhotoIds = state.deletingPhotoIds,
imageAuthHeader = state.imageAuthHeader, imageAuthHeader = state.imageAuthHeader,
allowGalleryUploads = state.allowGalleryUploads, allowGalleryUploads = state.allowGalleryUploads,
canManagePhotos = canManagePhotos, canManagePhotos = canManagePhotos,
onPhoto = onPhoto, onPhoto = onPhoto,
onDeletePhoto = onDeletePhoto, onDeletePhoto = onDeletePhoto,
onDeleteUpload = onDeleteUpload,
onRetryUpload = onRetryUpload,
onCamera = { onCamera = {
val newUri = createCameraUri(context) val newUri = createCameraUri(context)
cameraUri = newUri cameraUri = newUri
@@ -964,11 +979,15 @@ private fun RouteFact(label: String, value: String, modifier: Modifier = Modifie
@Composable @Composable
private fun CargoDocumentationSection( private fun CargoDocumentationSection(
photos: List<RoutePhotoDto>, photos: List<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
imageAuthHeader: String?, imageAuthHeader: String?,
allowGalleryUploads: Boolean, allowGalleryUploads: Boolean,
canManagePhotos: Boolean, canManagePhotos: Boolean,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
onCamera: () -> Unit, onCamera: () -> Unit,
onGallery: () -> Unit, onGallery: () -> Unit,
) { ) {
@@ -990,7 +1009,7 @@ private fun CargoDocumentationSection(
} }
} }
} }
PhotoGrid(photos, imageAuthHeader, onPhoto, onDeletePhoto) PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onDeleteUpload, onRetryUpload)
} }
} }
@@ -1011,12 +1030,18 @@ private fun CargoActionButton(label: String, icon: ImageVector, color: Color, on
@Composable @Composable
private fun PhotoGrid( private fun PhotoGrid(
photos: List<RoutePhotoDto>, photos: List<RoutePhotoDto>,
uploads: List<PhotoUploadEntity>,
deletingPhotoIds: Set<String>,
imageAuthHeader: String?, imageAuthHeader: String?,
onPhoto: (RoutePhotoDto) -> Unit, onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit, onDeletePhoto: (RoutePhotoDto) -> Unit,
onDeleteUpload: (PhotoUploadEntity) -> Unit,
onRetryUpload: (PhotoUploadEntity) -> Unit,
) { ) {
val rows = ceil((photos.size.coerceAtLeast(1) / 2f).toDouble()).toInt() var pendingDelete by remember { mutableStateOf<PhotoDeleteTarget?>(null) }
val gridHeight = (rows * 172).coerceIn(172, 520).dp val items = (uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) })
.sortedByDescending { it.sortEpochMillis }
val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size)
Card( Card(
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
@@ -1025,51 +1050,210 @@ private fun PhotoGrid(
) { ) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text( Text(
"Załączone zdjęcia (${photos.size})", "Załączone zdjęcia ($visibleAttachmentCount)",
color = TppColors.Muted, color = TppColors.Muted,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
if (photos.isEmpty()) { if (items.isEmpty()) {
EmptyPhotoState() EmptyPhotoState()
} else { } else {
LazyVerticalGrid( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
columns = GridCells.Fixed(2), repeat(inlinePhotoGridRows(items.size)) { rowIndex ->
modifier = Modifier.height(gridHeight), val startIndex = rowIndex * 2
verticalArrangement = Arrangement.spacedBy(12.dp), val rowItems = items.subList(startIndex, minOf(startIndex + 2, items.size))
horizontalArrangement = Arrangement.spacedBy(12.dp), Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
) { rowItems.forEach { item ->
items(photos, key = { it.id }) { photo -> when (item) {
Box(Modifier.aspectRatio(1f)) { is PhotoGridItem.Server -> PhotoTile(
AsyncImage( photo = item.photo,
model = imageRequest(photo.url, imageAuthHeader), imageAuthHeader = imageAuthHeader,
contentDescription = "Zdjecie ladunku", isDeleting = item.photo.id in deletingPhotoIds,
contentScale = ContentScale.Crop, onPhoto = onPhoto,
modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) }, onDeletePhoto = { pendingDelete = PhotoDeleteTarget.Server(it) },
) modifier = Modifier.weight(1f),
if (photo.canDelete) { )
IconButton( is PhotoGridItem.Upload -> PendingPhotoTile(
onClick = { onDeletePhoto(photo) }, upload = item.upload,
modifier = Modifier isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
.align(Alignment.TopEnd) onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
.padding(6.dp) onRetry = onRetryUpload,
.size(34.dp) modifier = Modifier.weight(1f),
.background(TppColors.Error, RoundedCornerShape(17.dp)),
) {
Icon(
Icons.Outlined.Delete,
contentDescription = "Usuń zdjęcie",
tint = Color.White,
modifier = Modifier.size(18.dp),
) )
} }
} }
if (rowItems.size == 1) {
Spacer(Modifier.weight(1f))
}
} }
} }
} }
} }
} }
} }
pendingDelete?.let { target ->
AlertDialog(
onDismissRequest = { pendingDelete = null },
title = { Text("Usunąć zdjęcie?", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = { Text("Czy na pewno chcesz usunąć to zdjęcie?", color = TppColors.Muted) },
confirmButton = {
TextButton(
onClick = {
pendingDelete = null
when (target) {
is PhotoDeleteTarget.Server -> onDeletePhoto(target.photo)
is PhotoDeleteTarget.Upload -> onDeleteUpload(target.upload)
}
},
) {
Text("Usuń", color = TppColors.Error, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { pendingDelete = null }) {
Text("Anuluj", color = TppColors.Muted)
}
},
)
}
}
private sealed class PhotoGridItem(open val sortEpochMillis: Long) {
data class Server(val photo: RoutePhotoDto) : PhotoGridItem(
routePhotoSortEpochMillis(photo.createdAt, photo.takenAt, fallback = 0L),
)
data class Upload(val upload: PhotoUploadEntity) : PhotoGridItem(upload.createdAtEpochMillis)
}
private sealed class PhotoDeleteTarget {
data class Server(val photo: RoutePhotoDto) : PhotoDeleteTarget()
data class Upload(val upload: PhotoUploadEntity) : PhotoDeleteTarget()
}
@Composable
private fun PhotoTile(
photo: RoutePhotoDto,
imageAuthHeader: String?,
isDeleting: Boolean,
onPhoto: (RoutePhotoDto) -> Unit,
onDeletePhoto: (RoutePhotoDto) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.aspectRatio(1f)) {
AsyncImage(
model = imageRequest(photo.url, imageAuthHeader),
contentDescription = "Zdjecie ladunku",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) },
)
if (isDeleting) {
Box(
Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)),
contentAlignment = Alignment.Center,
) {
Text("Usuwam", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
}
if (photo.canDelete) {
IconButton(
onClick = { onDeletePhoto(photo) },
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.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
) {
Icon(
Icons.Outlined.Delete,
contentDescription = "Usuń zdjęcie",
tint = TppColors.Error,
modifier = Modifier.size(19.dp),
)
}
}
}
}
@Composable
private fun PendingPhotoTile(
upload: PhotoUploadEntity,
isDeleting: Boolean,
onDelete: (PhotoUploadEntity) -> Unit,
onRetry: (PhotoUploadEntity) -> Unit,
modifier: Modifier = Modifier,
) {
val status = upload.statusType
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
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(),
)
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.34f)))
if (isDeleting) {
Box(
Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.72f)),
contentAlignment = Alignment.Center,
) {
Text("Usuwam", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
}
if (canDeleteServerPhoto) {
IconButton(
onClick = { onDelete(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.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
) {
Icon(
Icons.Outlined.Delete,
contentDescription = "Usuń zdjęcie",
tint = TppColors.Error,
modifier = Modifier.size(19.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,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
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 (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)
}
}
}
}
} }
@Composable @Composable
@@ -1,6 +1,26 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean = fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false) runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
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 routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Long): Long =
parseIsoOffsetEpochMillis(createdAt)
?: parseIsoOffsetEpochMillis(takenAt)
?: fallback
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
@@ -4,16 +4,20 @@ import android.app.Application
import android.net.Uri import android.net.Uri
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
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.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo } enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo }
@@ -33,14 +37,18 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true, val allowGalleryUploads: Boolean = true,
val selectedRoute: DriverRouteDto? = null, val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val deletingPhotoIds: Set<String> = emptySet(),
val imageAuthHeader: String? = null, val imageAuthHeader: String? = null,
val error: String? = null, val error: String? = null,
) )
class DriverViewModel(application: Application) : AndroidViewModel(application) { class DriverViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DriverRepository(application) private val repository = DriverRepository(application)
private val photoUploadOutbox = PhotoUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true)) private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private var photoUploadsJob: Job? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
init { init {
@@ -126,7 +134,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun openRoute(routeId: String) = runLoading { fun openRoute(routeId: String) = runLoading {
val response = repository.route(routeId) val response = repository.route(routeId)
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, error = null) }
observePhotoUploads(routeId)
} }
fun refreshSelectedRoute() { fun refreshSelectedRoute() {
@@ -137,6 +147,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
runCatching { repository.route(routeId) } runCatching { repository.route(routeId) }
.onSuccess { response -> .onSuccess { response ->
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update { _state.update {
it.copy( it.copy(
selectedRoute = response.route, selectedRoute = response.route,
@@ -157,16 +168,71 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Profile, error = null) } _state.update { it.copy(screen = DriverScreen.Profile, error = null) }
} }
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) = runLoading { fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val route = _state.value.selectedRoute ?: return@runLoading val route = _state.value.selectedRoute ?: return
repository.uploadPhoto(route.id, uri, source, metadata) viewModelScope.launch {
openRoute(route.id) _state.update { it.copy(error = null) }
runCatching { photoUploadOutbox.enqueue(route.id, uri, source, metadata) }
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
}
} }
fun deletePhoto(photo: RoutePhotoDto) = runLoading { fun retryPhotoUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return@runLoading viewModelScope.launch {
repository.deletePhoto(photo.id) runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
openRoute(route.id) .onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
}
}
fun deletePhoto(photo: RoutePhotoDto) {
val route = _state.value.selectedRoute ?: return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + photo.id, error = null) }
runCatching {
repository.deletePhoto(photo.id)
val response = repository.route(route.id)
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { item -> if (item.id == route.id) response.route else item },
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - photo.id) }
}
}
fun deleteConfirmedUpload(upload: PhotoUploadEntity) {
val route = _state.value.selectedRoute ?: return
val serverPhotoId = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) ?: return
viewModelScope.launch {
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds + serverPhotoId, error = null) }
runCatching {
repository.deletePhoto(serverPhotoId)
photoUploadOutbox.discard(upload.clientRequestId)
val response = repository.route(route.id)
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
_state.update {
it.copy(
selectedRoute = response.route,
routes = it.routes.map { item -> if (item.id == route.id) response.route else item },
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(deletingPhotoIds = it.deletingPhotoIds - serverPhotoId) }
}
} }
fun openPhoto(photo: RoutePhotoDto) { fun openPhoto(photo: RoutePhotoDto) {
@@ -177,7 +243,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { _state.update {
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.Detail -> it.copy(screen = DriverScreen.Routes, selectedRoute = null) DriverScreen.Detail -> {
photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList())
}
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes) DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone) DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
else -> it else -> it
@@ -186,6 +255,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
fun logout() = runLoading { fun logout() = runLoading {
photoUploadsJob?.cancel()
repository.logout() repository.logout()
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) } _state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
} }
@@ -195,9 +265,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(loading = true, error = null) } _state.update { it.copy(loading = true, error = null) }
runCatching { block() } runCatching { block() }
.onFailure { throwable -> .onFailure { throwable ->
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") } _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
} }
_state.update { it.copy(loading = false) } _state.update { it.copy(loading = false) }
} }
} }
private fun observePhotoUploads(routeId: String) {
photoUploadsJob?.cancel()
photoUploadsJob = viewModelScope.launch {
photoUploadOutbox.observeVisibleForRoute(routeId).collect { uploads ->
_state.update { it.copy(photoUploads = uploads) }
}
}
}
} }
@@ -0,0 +1,42 @@
package pl.firmatpp.kierowca.data
import java.io.IOException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ApiErrorMapperTest {
@Test
fun mapsNetworkFailuresToRetryableOfflineMessage() {
val error = ApiErrorMapper.map(IOException("timeout"))
assertEquals(ApiErrorKind.Network, error.kind)
assertTrue(error.retryable)
assertEquals("Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", error.message)
}
@Test
fun mapsMobileApiProblemCodes() {
val error = ApiErrorMapper.mapProblem(
statusCode = 422,
code = "PHOTO_IDEMPOTENCY_CONFLICT",
message = "Ten upload został już użyty dla innego zdjęcia.",
retryable = false,
)
assertEquals(ApiErrorKind.Validation, error.kind)
assertFalse(error.retryable)
assertEquals("PHOTO_IDEMPOTENCY_CONFLICT", error.code)
assertEquals("Ten upload został już użyty dla innego zdjęcia.", error.message)
}
@Test
fun mapsServerErrorsToRetryableUnconfirmedMessage() {
val error = ApiErrorMapper.mapHttpStatus(503, null)
assertEquals(ApiErrorKind.Server, error.kind)
assertTrue(error.retryable)
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
}
}
@@ -0,0 +1,64 @@
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.PhotoUploadReceiptDto
class PhotoUploadReceiptVerifierTest {
private val upload = PhotoUploadEntity(
clientRequestId = "6f7a7d10-b7f7-41ab-8f5e-f1afc3e93736",
routeId = "10",
localPath = "/tmp/photo.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
@Test
fun confirmsOnlyMatchingReceipt() {
assertTrue(
PhotoUploadReceiptVerifier.matches(
upload,
PhotoUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = upload.contentSha256.uppercase(),
storedAt = "2026-07-01T10:00:00+02:00",
),
),
)
}
@Test
fun rejectsDifferentRequestOrHash() {
assertFalse(
PhotoUploadReceiptVerifier.matches(
upload,
PhotoUploadReceiptDto(
clientRequestId = "other",
serverPhotoId = "55",
contentSha256 = upload.contentSha256,
storedAt = null,
),
),
)
assertFalse(
PhotoUploadReceiptVerifier.matches(
upload,
PhotoUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
storedAt = null,
),
),
)
}
}
@@ -0,0 +1,29 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PhotoUploadStatusTest {
@Test
fun onlyConfirmedStatusIsStoredOnServer() {
assertFalse(PhotoUploadStatus.Pending.isStoredOnServer)
assertFalse(PhotoUploadStatus.Uploading.isStoredOnServer)
assertFalse(PhotoUploadStatus.Verifying.isStoredOnServer)
assertFalse(PhotoUploadStatus.FailedRetryable.isStoredOnServer)
assertFalse(PhotoUploadStatus.FailedPermanent.isStoredOnServer)
assertFalse(PhotoUploadStatus.Cancelled.isStoredOnServer)
assertTrue(PhotoUploadStatus.Confirmed.isStoredOnServer)
}
@Test
fun statusLabelsMakeUnconfirmedStateExplicit() {
assertEquals("Czeka na wysłanie", PhotoUploadStatus.Pending.label)
assertEquals("Wysyłam zdjęcie", PhotoUploadStatus.Uploading.label)
assertEquals("Sprawdzam zapis", PhotoUploadStatus.Verifying.label)
assertEquals("Nie wysłano, ponów", PhotoUploadStatus.FailedRetryable.label)
assertEquals("Zdjęcie nie zostało zapisane", PhotoUploadStatus.FailedPermanent.label)
assertEquals("Zapisane", PhotoUploadStatus.Confirmed.label)
}
}
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.time.LocalDate import java.time.LocalDate
import org.junit.Assert.assertEquals
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
@@ -20,4 +21,51 @@ class DriverUiRulesTest {
assertFalse(canManageRoutePhotos("", today)) assertFalse(canManageRoutePhotos("", today))
assertFalse(canManageRoutePhotos("not-a-date", today)) assertFalse(canManageRoutePhotos("not-a-date", today))
} }
@Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0))
assertEquals(1, inlinePhotoGridRows(1))
assertEquals(1, inlinePhotoGridRows(2))
assertEquals(2, inlinePhotoGridRows(3))
assertEquals(3, inlinePhotoGridRows(5))
}
@Test
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))
assertEquals(1, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 1))
assertEquals(3, visiblePhotoAttachmentCount(serverPhotoCount = 1, localUploadCount = 2))
}
@Test
fun usesCreatedAtBeforeTakenAtForNewestFirstPhotoSorting() {
val fallback = 100L
assertEquals(
1_782_892_800_000L,
routePhotoSortEpochMillis(
createdAt = "2026-07-01T10:00:00+02:00",
takenAt = "2026-07-01T08:00:00+02:00",
fallback = fallback,
),
)
assertEquals(
1_782_885_600_000L,
routePhotoSortEpochMillis(
createdAt = null,
takenAt = "2026-07-01T08:00:00+02:00",
fallback = fallback,
),
)
assertEquals(fallback, routePhotoSortEpochMillis(createdAt = null, takenAt = null, fallback = fallback))
}
@Test
fun allowsDeleteIconForConfirmedLocalUploadWithServerPhotoId() {
assertEquals("42", confirmedUploadServerPhotoId("CONFIRMED", "42"))
assertEquals(null, confirmedUploadServerPhotoId("CONFIRMED", ""))
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
}
} }
+6
View File
@@ -13,6 +13,8 @@ datastore = "1.1.1"
coil = "2.7.0" coil = "2.7.0"
camerax = "1.4.1" camerax = "1.4.1"
work = "2.10.0" work = "2.10.0"
room = "2.7.0"
ksp = "2.1.10-1.0.31"
playServicesAuth = "21.6.0" playServicesAuth = "21.6.0"
playServicesAuthApiPhone = "18.3.0" playServicesAuthApiPhone = "18.3.0"
junit = "4.13.2" junit = "4.13.2"
@@ -25,6 +27,9 @@ androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lif
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
@@ -51,3 +56,4 @@ retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", ver
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }