Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08fe31d3d | ||
|
|
060896d12b | ||
|
|
1a26817d2c |
@@ -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 = 13
|
versionCode = 16
|
||||||
versionName = "1.0.12"
|
versionName = "1.0.15"
|
||||||
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,112 @@
|
|||||||
|
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 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
|
||||||
@@ -135,7 +135,7 @@ fun DriverApp(viewModel: DriverViewModel) {
|
|||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
var appInForeground by remember { mutableStateOf(true) }
|
var appInForeground by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
BackHandler(enabled = state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
|
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
|
||||||
viewModel.back()
|
viewModel.back()
|
||||||
}
|
}
|
||||||
DisposableEffect(lifecycleOwner, state.screen) {
|
DisposableEffect(lifecycleOwner, state.screen) {
|
||||||
@@ -168,6 +168,7 @@ fun DriverApp(viewModel: DriverViewModel) {
|
|||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
|
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
|
||||||
when (state.screen) {
|
when (state.screen) {
|
||||||
|
DriverScreen.Initializing -> StartupScreen()
|
||||||
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
|
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
|
||||||
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
|
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
|
||||||
DriverScreen.Routes -> RoutesScreen(
|
DriverScreen.Routes -> RoutesScreen(
|
||||||
@@ -178,11 +179,11 @@ 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::retryPhotoUpload, viewModel::refreshSelectedRoute)
|
||||||
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
|
DriverScreen.Photo -> PhotoScreen(state, viewModel::back)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading && state.screen != DriverScreen.Initializing) {
|
||||||
Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.42f)), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.42f)), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator(color = TppColors.Forest)
|
CircularProgressIndicator(color = TppColors.Forest)
|
||||||
}
|
}
|
||||||
@@ -190,6 +191,37 @@ fun DriverApp(viewModel: DriverViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StartupScreen() {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(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(
|
||||||
|
"Sprawdzanie sesji",
|
||||||
|
color = TppColors.Muted,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||||
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
||||||
@@ -706,6 +738,7 @@ 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,
|
||||||
|
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -744,11 +777,14 @@ 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,
|
||||||
|
onRetryUpload = onRetryUpload,
|
||||||
onCamera = {
|
onCamera = {
|
||||||
val newUri = createCameraUri(context)
|
val newUri = createCameraUri(context)
|
||||||
cameraUri = newUri
|
cameraUri = newUri
|
||||||
@@ -932,11 +968,14 @@ 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,
|
||||||
|
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||||
onCamera: () -> Unit,
|
onCamera: () -> Unit,
|
||||||
onGallery: () -> Unit,
|
onGallery: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -958,7 +997,7 @@ private fun CargoDocumentationSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PhotoGrid(photos, imageAuthHeader, onPhoto, onDeletePhoto)
|
PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onRetryUpload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,12 +1018,17 @@ 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,
|
||||||
|
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||||
) {
|
) {
|
||||||
val rows = ceil((photos.size.coerceAtLeast(1) / 2f).toDouble()).toInt()
|
var photoPendingDelete by remember { mutableStateOf<RoutePhotoDto?>(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),
|
||||||
@@ -993,47 +1037,166 @@ 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 ->
|
||||||
|
when (item) {
|
||||||
|
is PhotoGridItem.Server -> PhotoTile(
|
||||||
|
photo = item.photo,
|
||||||
|
imageAuthHeader = imageAuthHeader,
|
||||||
|
isDeleting = item.photo.id in deletingPhotoIds,
|
||||||
|
onPhoto = onPhoto,
|
||||||
|
onDeletePhoto = { photoPendingDelete = it },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
is PhotoGridItem.Upload -> PendingPhotoTile(
|
||||||
|
upload = item.upload,
|
||||||
|
onRetry = onRetryUpload,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rowItems.size == 1) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
photoPendingDelete?.let { photo ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { photoPendingDelete = 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 = {
|
||||||
|
photoPendingDelete = null
|
||||||
|
onDeletePhoto(photo)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
items(photos, key = { it.id }) { photo ->
|
Text("Usuń", color = TppColors.Error, fontWeight = FontWeight.Bold)
|
||||||
Box(Modifier.aspectRatio(1f)) {
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { photoPendingDelete = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhotoTile(
|
||||||
|
photo: RoutePhotoDto,
|
||||||
|
imageAuthHeader: String?,
|
||||||
|
isDeleting: Boolean,
|
||||||
|
onPhoto: (RoutePhotoDto) -> Unit,
|
||||||
|
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier.aspectRatio(1f)) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageRequest(photo.url, imageAuthHeader),
|
model = imageRequest(photo.url, imageAuthHeader),
|
||||||
contentDescription = "Zdjecie ladunku",
|
contentDescription = "Zdjecie ladunku",
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) },
|
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) {
|
if (photo.canDelete) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onDeletePhoto(photo) },
|
onClick = { onDeletePhoto(photo) },
|
||||||
|
enabled = !isDeleting,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
.padding(6.dp)
|
.padding(8.dp)
|
||||||
.size(34.dp)
|
.size(40.dp)
|
||||||
.background(TppColors.Error, RoundedCornerShape(17.dp)),
|
.background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
|
||||||
|
.border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Delete,
|
Icons.Outlined.Delete,
|
||||||
contentDescription = "Usuń zdjęcie",
|
contentDescription = "Usuń zdjęcie",
|
||||||
tint = Color.White,
|
tint = TppColors.Error,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(19.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PendingPhotoTile(
|
||||||
|
upload: PhotoUploadEntity,
|
||||||
|
onRetry: (PhotoUploadEntity) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val status = upload.statusType
|
||||||
|
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)))
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
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
|
||||||
|
|
||||||
|
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
|
||||||
|
value?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,22 +4,26 @@ 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 { Phone, Otp, Routes, Profile, Detail, Photo }
|
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo }
|
||||||
|
|
||||||
data class DriverUiState(
|
data class DriverUiState(
|
||||||
val screen: DriverScreen = DriverScreen.Phone,
|
val screen: DriverScreen = DriverScreen.Initializing,
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = true,
|
||||||
val refreshing: Boolean = false,
|
val refreshing: Boolean = false,
|
||||||
val phone: String = "",
|
val phone: String = "",
|
||||||
val otpCode: String = "",
|
val otpCode: String = "",
|
||||||
@@ -33,19 +37,27 @@ 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 {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (repository.hasToken()) refreshRoutes() else _state.update { it.copy(loading = false) }
|
if (repository.hasToken()) {
|
||||||
|
refreshRoutes()
|
||||||
|
} else {
|
||||||
|
_state.update { it.copy(screen = DriverScreen.Phone, loading = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +118,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { throwable ->
|
}.onFailure { throwable ->
|
||||||
_state.update { it.copy(error = throwable.message ?: "Wystapil blad.") }
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
screen = if (it.screen == DriverScreen.Initializing) DriverScreen.Phone else it.screen,
|
||||||
|
error = throwable.message ?: "Wystapil blad.",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_state.update {
|
_state.update {
|
||||||
@@ -117,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() {
|
||||||
@@ -128,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,
|
||||||
@@ -148,16 +168,46 @@ 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 {
|
||||||
|
runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
|
||||||
|
.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)
|
repository.deletePhoto(photo.id)
|
||||||
openRoute(route.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 openPhoto(photo: RoutePhotoDto) {
|
fun openPhoto(photo: RoutePhotoDto) {
|
||||||
@@ -168,7 +218,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
|
||||||
@@ -177,8 +230,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun logout() = runLoading {
|
fun logout() = runLoading {
|
||||||
|
photoUploadsJob?.cancel()
|
||||||
repository.logout()
|
repository.logout()
|
||||||
_state.update { DriverUiState(screen = DriverScreen.Phone) }
|
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runLoading(block: suspend () -> Unit) {
|
private fun runLoading(block: suspend () -> Unit) {
|
||||||
@@ -186,9 +240,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,43 @@ 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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package pl.firmatpp.kierowca.ui
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DriverUiStateTest {
|
||||||
|
@Test
|
||||||
|
fun startsOnInitializingScreenBeforeTokenCheckCompletes() {
|
||||||
|
val state = DriverUiState()
|
||||||
|
|
||||||
|
assertEquals(DriverScreen.Initializing, state.screen)
|
||||||
|
assertTrue(state.loading)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user