Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06557e77b8 | ||
|
|
a08fe31d3d | ||
|
|
060896d12b | ||
|
|
1a26817d2c | ||
|
|
693f879078 | ||
|
|
0bc592f357 | ||
|
|
9b6564eedd |
@@ -2,6 +2,7 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,8 +13,8 @@ android {
|
||||
applicationId = "pl.firmatpp.kierowca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 11
|
||||
versionName = "1.0.10"
|
||||
versionCode = 17
|
||||
versionName = "1.0.16"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
|
||||
@@ -45,6 +46,10 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += "NullSafeMutableLiveData"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -57,6 +62,8 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.camera.camera2)
|
||||
implementation(libs.camera.core)
|
||||
@@ -81,4 +88,6 @@ dependencies {
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
||||
ksp(libs.androidx.room.compiler)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TppKierowca">
|
||||
<activity
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pl.firmatpp.kierowca.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import pl.firmatpp.kierowca.BuildConfig
|
||||
import pl.firmatpp.kierowca.data.model.DriverDeviceInfo
|
||||
|
||||
class DeviceInfoProvider(
|
||||
private val context: Context,
|
||||
private val tokenStore: TokenStore,
|
||||
) {
|
||||
suspend fun currentDeviceInfo(): DriverDeviceInfo =
|
||||
DriverDeviceInfo(
|
||||
deviceId = tokenStore.deviceId(),
|
||||
androidId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID),
|
||||
brand = Build.BRAND,
|
||||
manufacturer = Build.MANUFACTURER,
|
||||
model = Build.MODEL,
|
||||
device = Build.DEVICE,
|
||||
product = Build.PRODUCT,
|
||||
androidVersion = Build.VERSION.RELEASE,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
serialNumber = readSerialNumber(),
|
||||
)
|
||||
|
||||
private fun readSerialNumber(): String? =
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Build.getSerial()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Build.SERIAL
|
||||
}
|
||||
}.getOrNull()
|
||||
?.takeUnless { it.isBlank() || it.equals(Build.UNKNOWN, ignoreCase = true) }
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package pl.firmatpp.kierowca.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import pl.firmatpp.kierowca.data.api.ApiFactory
|
||||
import pl.firmatpp.kierowca.data.api.MobileDriverApi
|
||||
@@ -14,17 +18,19 @@ import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
||||
import pl.firmatpp.kierowca.data.model.RequestOtpBody
|
||||
import pl.firmatpp.kierowca.data.model.RouteResponse
|
||||
import pl.firmatpp.kierowca.data.model.VerifyOtpBody
|
||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||
|
||||
class DriverRepository(
|
||||
private val context: Context,
|
||||
private val api: MobileDriverApi = ApiFactory.mobileDriverApi(),
|
||||
private val tokenStore: TokenStore = TokenStore(context),
|
||||
private val deviceInfoProvider: DeviceInfoProvider = DeviceInfoProvider(context, tokenStore),
|
||||
) {
|
||||
suspend fun requestOtp(phoneNumber: String): OtpResponse =
|
||||
api.requestOtp(RequestOtpBody(phoneNumber))
|
||||
|
||||
suspend fun verifyOtp(phoneNumber: String, code: String, deviceName: String): DriverDto {
|
||||
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName))
|
||||
val response = api.verifyOtp(VerifyOtpBody(phoneNumber, code, deviceName, deviceInfoProvider.currentDeviceInfo()))
|
||||
val token = response.accessToken ?: error("Brak tokenu sesji.")
|
||||
tokenStore.save(token)
|
||||
return response.driver ?: error("Brak danych kierowcy.")
|
||||
@@ -45,6 +51,8 @@ class DriverRepository(
|
||||
val resolver = context.contentResolver
|
||||
val mimeType = resolver.getType(uri) ?: "image/jpeg"
|
||||
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 photo = MultipartBody.Part.createFormData("photo", "ladunek.jpg", body)
|
||||
val sourceBody = source.toRequestBody("text/plain".toMediaTypeOrNull())
|
||||
@@ -52,8 +60,11 @@ class DriverRepository(
|
||||
|
||||
return api.uploadPhoto(
|
||||
authHeader(requireToken()),
|
||||
clientRequestId,
|
||||
routeId,
|
||||
photo,
|
||||
clientRequestId.toPlainTextBody(),
|
||||
contentSha256.toPlainTextBody(),
|
||||
sourceBody,
|
||||
metadataParts["takenAt"],
|
||||
metadataParts["latitude"],
|
||||
@@ -62,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) {
|
||||
api.deletePhoto(authHeader(requireToken()), photoId)
|
||||
}
|
||||
@@ -81,4 +118,11 @@ class DriverRepository(
|
||||
private suspend fun requireToken(): String = tokenStore.read() ?: error("Brak aktywnej sesji.")
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.UUID
|
||||
|
||||
private val Context.driverDataStore by preferencesDataStore(name = "driver_session")
|
||||
|
||||
class TokenStore(private val context: Context) {
|
||||
private val tokenKey = stringPreferencesKey("access_token")
|
||||
private val deviceIdKey = stringPreferencesKey("device_id")
|
||||
|
||||
suspend fun save(token: String) {
|
||||
context.driverDataStore.edit { preferences ->
|
||||
@@ -22,6 +24,21 @@ class TokenStore(private val context: Context) {
|
||||
.map { it[tokenKey] }
|
||||
.first()
|
||||
|
||||
suspend fun deviceId(): String {
|
||||
val existing = context.driverDataStore.data
|
||||
.map { it[deviceIdKey] }
|
||||
.first()
|
||||
|
||||
if (!existing.isNullOrBlank()) return existing
|
||||
|
||||
val generated = UUID.randomUUID().toString()
|
||||
context.driverDataStore.edit { preferences ->
|
||||
preferences[deviceIdKey] = generated
|
||||
}
|
||||
|
||||
return generated
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
context.driverDataStore.edit { it.remove(tokenKey) }
|
||||
}
|
||||
|
||||
@@ -45,8 +45,11 @@ interface MobileDriverApi {
|
||||
@POST("mobile/driver/routes/{routeId}/photos")
|
||||
suspend fun uploadPhoto(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Header("Idempotency-Key") idempotencyKey: String,
|
||||
@Path("routeId") routeId: String,
|
||||
@Part photo: MultipartBody.Part,
|
||||
@Part("clientRequestId") clientRequestId: RequestBody,
|
||||
@Part("contentSha256") contentSha256: RequestBody,
|
||||
@Part("source") source: RequestBody,
|
||||
@Part("takenAt") takenAt: RequestBody?,
|
||||
@Part("latitude") latitude: RequestBody?,
|
||||
|
||||
@@ -6,6 +6,21 @@ data class VerifyOtpBody(
|
||||
val phoneNumber: String,
|
||||
val code: String,
|
||||
val deviceName: String,
|
||||
val device: DriverDeviceInfo,
|
||||
)
|
||||
|
||||
data class DriverDeviceInfo(
|
||||
val deviceId: String,
|
||||
val androidId: String?,
|
||||
val brand: String?,
|
||||
val manufacturer: String?,
|
||||
val model: String?,
|
||||
val device: String?,
|
||||
val product: String?,
|
||||
val androidVersion: String?,
|
||||
val sdkInt: Int,
|
||||
val appVersion: String?,
|
||||
val serialNumber: String?,
|
||||
)
|
||||
|
||||
data class OtpResponse(
|
||||
@@ -91,6 +106,8 @@ data class NavigationPointDto(
|
||||
data class RoutePhotoDto(
|
||||
val id: String,
|
||||
val routeId: String,
|
||||
val clientRequestId: String? = null,
|
||||
val contentSha256: String? = null,
|
||||
val source: String,
|
||||
val mimeType: String?,
|
||||
val size: Long,
|
||||
@@ -106,4 +123,12 @@ data class RoutePhotoDto(
|
||||
|
||||
data class PhotoUploadResponse(
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -40,12 +41,11 @@ import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
@@ -62,6 +62,7 @@ import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -75,6 +76,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -92,11 +94,14 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -111,7 +116,6 @@ import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.math.ceil
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.Locale
|
||||
import pl.firmatpp.kierowca.R
|
||||
@@ -119,6 +123,8 @@ import pl.firmatpp.kierowca.data.PhotoUploadMetadata
|
||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||
import pl.firmatpp.kierowca.data.model.NavigationPointDto
|
||||
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.RouteDisplayMapper
|
||||
import pl.firmatpp.kierowca.ui.theme.TppColors
|
||||
@@ -129,7 +135,7 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
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()
|
||||
}
|
||||
DisposableEffect(lifecycleOwner, state.screen) {
|
||||
@@ -162,6 +168,7 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
|
||||
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
|
||||
when (state.screen) {
|
||||
DriverScreen.Initializing -> StartupScreen()
|
||||
DriverScreen.Phone -> PhoneScreen(state, viewModel::requestOtp)
|
||||
DriverScreen.Otp -> OtpScreen(state, viewModel::updateOtpCode, viewModel::verifyOtp, viewModel::back)
|
||||
DriverScreen.Routes -> RoutesScreen(
|
||||
@@ -172,11 +179,20 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
onRoute = viewModel::openRoute,
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
if (state.loading && state.screen != DriverScreen.Initializing) {
|
||||
Box(Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.42f)), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = TppColors.Forest)
|
||||
}
|
||||
@@ -185,21 +201,166 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
var phone by remember { mutableStateOf(state.phone) }
|
||||
AuthShell(title = "TPP Kierowca", subtitle = "Zaloguj sie numerem telefonu kierowcy.") {
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it },
|
||||
label = { Text("Numer telefonu") },
|
||||
leadingIcon = { Icon(Icons.Outlined.Phone, contentDescription = null) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
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
|
||||
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(TppColors.Surface)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(24.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 28.dp, vertical = 36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.tpp5),
|
||||
contentDescription = "Firma TPP",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(132.dp),
|
||||
)
|
||||
Spacer(Modifier.height(42.dp))
|
||||
Text(
|
||||
"Zaloguj się",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = TppColors.Muted,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(Modifier.height(76.dp))
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Text(
|
||||
"Numer Telefonu",
|
||||
color = Color.Black,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 23.sp,
|
||||
)
|
||||
PhoneNumberField(
|
||||
digits = phoneDigits,
|
||||
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
|
||||
)
|
||||
Text(
|
||||
"Otrzymasz kod SMS do weryfikacji.",
|
||||
color = TppColors.Muted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
ErrorText(state.error)
|
||||
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 22.dp else 8.dp))
|
||||
Button(
|
||||
onClick = { onSubmit("+48$phoneDigits") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(72.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
) {
|
||||
Text(
|
||||
"Wyślij kod OTP",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(82.dp)
|
||||
.background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp))
|
||||
.border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(94.dp)
|
||||
.fillMaxHeight()
|
||||
.background(TppColors.Panel),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("+48", color = TppColors.Ink, fontSize = 26.sp)
|
||||
}
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
|
||||
BasicTextField(
|
||||
value = formatPolishPhoneDigits(digits),
|
||||
onValueChange = { onDigitsChange(it) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
textStyle = TextStyle(
|
||||
color = TppColors.Muted,
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (digits.isBlank()) {
|
||||
Text(
|
||||
"000 000 000",
|
||||
color = TppColors.Muted.copy(alpha = 0.92f),
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
ErrorText(state.error)
|
||||
Spacer(Modifier.height(if (state.error.isNullOrBlank()) 18.dp else 8.dp))
|
||||
PrimaryButton("Wyslij kod") { onSubmit(phone) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,6 +747,8 @@ private fun DetailScreen(
|
||||
onUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
|
||||
onPhoto: (RoutePhotoDto) -> Unit,
|
||||
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
||||
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
||||
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -624,11 +787,15 @@ private fun DetailScreen(
|
||||
item {
|
||||
CargoDocumentationSection(
|
||||
photos = route.photos,
|
||||
uploads = state.photoUploads,
|
||||
deletingPhotoIds = state.deletingPhotoIds,
|
||||
imageAuthHeader = state.imageAuthHeader,
|
||||
allowGalleryUploads = state.allowGalleryUploads,
|
||||
canManagePhotos = canManagePhotos,
|
||||
onPhoto = onPhoto,
|
||||
onDeletePhoto = onDeletePhoto,
|
||||
onDeleteUpload = onDeleteUpload,
|
||||
onRetryUpload = onRetryUpload,
|
||||
onCamera = {
|
||||
val newUri = createCameraUri(context)
|
||||
cameraUri = newUri
|
||||
@@ -812,11 +979,15 @@ private fun RouteFact(label: String, value: String, modifier: Modifier = Modifie
|
||||
@Composable
|
||||
private fun CargoDocumentationSection(
|
||||
photos: List<RoutePhotoDto>,
|
||||
uploads: List<PhotoUploadEntity>,
|
||||
deletingPhotoIds: Set<String>,
|
||||
imageAuthHeader: String?,
|
||||
allowGalleryUploads: Boolean,
|
||||
canManagePhotos: Boolean,
|
||||
onPhoto: (RoutePhotoDto) -> Unit,
|
||||
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
||||
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
||||
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||
onCamera: () -> Unit,
|
||||
onGallery: () -> Unit,
|
||||
) {
|
||||
@@ -838,7 +1009,7 @@ private fun CargoDocumentationSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
PhotoGrid(photos, imageAuthHeader, onPhoto, onDeletePhoto)
|
||||
PhotoGrid(photos, uploads, deletingPhotoIds, imageAuthHeader, onPhoto, onDeletePhoto, onDeleteUpload, onRetryUpload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,12 +1030,18 @@ private fun CargoActionButton(label: String, icon: ImageVector, color: Color, on
|
||||
@Composable
|
||||
private fun PhotoGrid(
|
||||
photos: List<RoutePhotoDto>,
|
||||
uploads: List<PhotoUploadEntity>,
|
||||
deletingPhotoIds: Set<String>,
|
||||
imageAuthHeader: String?,
|
||||
onPhoto: (RoutePhotoDto) -> Unit,
|
||||
onDeletePhoto: (RoutePhotoDto) -> Unit,
|
||||
onDeleteUpload: (PhotoUploadEntity) -> Unit,
|
||||
onRetryUpload: (PhotoUploadEntity) -> Unit,
|
||||
) {
|
||||
val rows = ceil((photos.size.coerceAtLeast(1) / 2f).toDouble()).toInt()
|
||||
val gridHeight = (rows * 172).coerceIn(172, 520).dp
|
||||
var pendingDelete by remember { mutableStateOf<PhotoDeleteTarget?>(null) }
|
||||
val items = (uploads.map { PhotoGridItem.Upload(it) } + photos.map { PhotoGridItem.Server(it) })
|
||||
.sortedByDescending { it.sortEpochMillis }
|
||||
val visibleAttachmentCount = visiblePhotoAttachmentCount(photos.size, uploads.size)
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
@@ -873,51 +1050,210 @@ private fun PhotoGrid(
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"Załączone zdjęcia (${photos.size})",
|
||||
"Załączone zdjęcia ($visibleAttachmentCount)",
|
||||
color = TppColors.Muted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
if (photos.isEmpty()) {
|
||||
if (items.isEmpty()) {
|
||||
EmptyPhotoState()
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.height(gridHeight),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(photos, key = { it.id }) { photo ->
|
||||
Box(Modifier.aspectRatio(1f)) {
|
||||
AsyncImage(
|
||||
model = imageRequest(photo.url, imageAuthHeader),
|
||||
contentDescription = "Zdjecie ladunku",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().clickable { onPhoto(photo) },
|
||||
)
|
||||
if (photo.canDelete) {
|
||||
IconButton(
|
||||
onClick = { onDeletePhoto(photo) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.size(34.dp)
|
||||
.background(TppColors.Error, RoundedCornerShape(17.dp)),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Delete,
|
||||
contentDescription = "Usuń zdjęcie",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(18.dp),
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
repeat(inlinePhotoGridRows(items.size)) { rowIndex ->
|
||||
val startIndex = rowIndex * 2
|
||||
val rowItems = items.subList(startIndex, minOf(startIndex + 2, items.size))
|
||||
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 = { pendingDelete = PhotoDeleteTarget.Server(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
is PhotoGridItem.Upload -> PendingPhotoTile(
|
||||
upload = item.upload,
|
||||
isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
|
||||
onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
|
||||
onRetry = onRetryUpload,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -998,6 +1334,16 @@ private fun ErrorText(error: String?) {
|
||||
if (!error.isNullOrBlank()) Text(error, color = TppColors.Error, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
private fun polishPhoneDigits(value: String): String {
|
||||
val digits = value.filter(Char::isDigit)
|
||||
val withoutCountryCode = if (digits.length > 9 && digits.startsWith("48")) digits.drop(2) else digits
|
||||
|
||||
return withoutCountryCode.take(9)
|
||||
}
|
||||
|
||||
private fun formatPolishPhoneDigits(value: String): String =
|
||||
value.chunked(3).joinToString(" ")
|
||||
|
||||
private fun createCameraUri(context: Context): Uri {
|
||||
val dir = File(context.cacheDir, "camera").apply { mkdirs() }
|
||||
val file = File.createTempFile("ladunek-", ".jpg", dir)
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
package pl.firmatpp.kierowca.ui
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
|
||||
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,22 +4,26 @@ import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.LocalDate
|
||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||
import pl.firmatpp.kierowca.data.DriverRepository
|
||||
import pl.firmatpp.kierowca.data.PhotoUploadMetadata
|
||||
import pl.firmatpp.kierowca.data.model.DriverDto
|
||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
|
||||
|
||||
enum class DriverScreen { Phone, Otp, Routes, Profile, Detail, Photo }
|
||||
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo }
|
||||
|
||||
data class DriverUiState(
|
||||
val screen: DriverScreen = DriverScreen.Phone,
|
||||
val loading: Boolean = false,
|
||||
val screen: DriverScreen = DriverScreen.Initializing,
|
||||
val loading: Boolean = true,
|
||||
val refreshing: Boolean = false,
|
||||
val phone: String = "",
|
||||
val otpCode: String = "",
|
||||
@@ -33,23 +37,33 @@ data class DriverUiState(
|
||||
val allowGalleryUploads: Boolean = true,
|
||||
val selectedRoute: DriverRouteDto? = null,
|
||||
val selectedPhoto: RoutePhotoDto? = null,
|
||||
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||
val deletingPhotoIds: Set<String> = emptySet(),
|
||||
val imageAuthHeader: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class DriverViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = DriverRepository(application)
|
||||
private val photoUploadOutbox = PhotoUploadOutbox(application)
|
||||
private val _state = MutableStateFlow(DriverUiState(loading = true))
|
||||
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
|
||||
private var photoUploadsJob: Job? = null
|
||||
val state: StateFlow<DriverUiState> = _state
|
||||
|
||||
init {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestOtp(phone: String) = runLoading {
|
||||
val response = repository.requestOtp(phone)
|
||||
otpAutoSubmitPolicy.reset()
|
||||
_state.update {
|
||||
it.copy(
|
||||
screen = DriverScreen.Otp,
|
||||
@@ -68,7 +82,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
fun updateOtpCode(code: String) {
|
||||
_state.update { it.copy(otpCode = code.filter(Char::isDigit).take(10)) }
|
||||
val sanitized = code.filter(Char::isDigit).take(6)
|
||||
_state.update { it.copy(otpCode = sanitized) }
|
||||
|
||||
if (otpAutoSubmitPolicy.shouldSubmit(sanitized, _state.value.loading)) {
|
||||
verifyOtp(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshRoutes() = loadRoutes(date = _state.value.selectedDate, showLoading = true, navigateToRoutes = true)
|
||||
@@ -99,7 +118,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
)
|
||||
}
|
||||
}.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 {
|
||||
@@ -110,7 +134,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
fun openRoute(routeId: String) = runLoading {
|
||||
val response = repository.route(routeId)
|
||||
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
|
||||
_state.update { it.copy(screen = DriverScreen.Detail, selectedRoute = response.route, error = null) }
|
||||
observePhotoUploads(routeId)
|
||||
}
|
||||
|
||||
fun refreshSelectedRoute() {
|
||||
@@ -121,6 +147,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
runCatching { repository.route(routeId) }
|
||||
.onSuccess { response ->
|
||||
photoUploadOutbox.discardConfirmedServerPhotos(response.route)
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedRoute = response.route,
|
||||
@@ -141,16 +168,71 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
|
||||
}
|
||||
|
||||
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) = runLoading {
|
||||
val route = _state.value.selectedRoute ?: return@runLoading
|
||||
repository.uploadPhoto(route.id, uri, source, metadata)
|
||||
openRoute(route.id)
|
||||
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
|
||||
val route = _state.value.selectedRoute ?: return
|
||||
viewModelScope.launch {
|
||||
_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 {
|
||||
val route = _state.value.selectedRoute ?: return@runLoading
|
||||
repository.deletePhoto(photo.id)
|
||||
openRoute(route.id)
|
||||
fun retryPhotoUpload(upload: PhotoUploadEntity) {
|
||||
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)
|
||||
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) {
|
||||
@@ -161,7 +243,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
_state.update {
|
||||
when (it.screen) {
|
||||
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.Otp -> it.copy(screen = DriverScreen.Phone)
|
||||
else -> it
|
||||
@@ -170,8 +255,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
|
||||
fun logout() = runLoading {
|
||||
photoUploadsJob?.cancel()
|
||||
repository.logout()
|
||||
_state.update { DriverUiState(screen = DriverScreen.Phone) }
|
||||
_state.update { DriverUiState(screen = DriverScreen.Phone, loading = false) }
|
||||
}
|
||||
|
||||
private fun runLoading(block: suspend () -> Unit) {
|
||||
@@ -179,9 +265,18 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
_state.update { it.copy(loading = true, error = null) }
|
||||
runCatching { block() }
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun observePhotoUploads(routeId: String) {
|
||||
photoUploadsJob?.cancel()
|
||||
photoUploadsJob = viewModelScope.launch {
|
||||
photoUploadOutbox.observeVisibleForRoute(routeId).collect { uploads ->
|
||||
_state.update { it.copy(photoUploads = uploads) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package pl.firmatpp.kierowca.ui
|
||||
|
||||
class OtpAutoSubmitPolicy {
|
||||
private var lastSubmittedCode: String? = null
|
||||
|
||||
fun shouldSubmit(code: String, loading: Boolean): Boolean {
|
||||
if (code.length < OTP_LENGTH) {
|
||||
lastSubmittedCode = null
|
||||
return false
|
||||
}
|
||||
|
||||
if (loading || code.length != OTP_LENGTH || code == lastSubmittedCode) {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSubmittedCode = code
|
||||
return true
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastSubmittedCode = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val OTP_LENGTH = 6
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#F7FAF8" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">TPP Kierowca</string>
|
||||
<string name="app_name">Firma TPP - Kierowca</string>
|
||||
</resources>
|
||||
|
||||
@@ -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
|
||||
|
||||
import java.time.LocalDate
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -20,4 +21,51 @@ class DriverUiRulesTest {
|
||||
assertFalse(canManageRoutePhotos("", 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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package pl.firmatpp.kierowca.ui
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class OtpAutoSubmitPolicyTest {
|
||||
@Test
|
||||
fun submitsWhenSixthDigitIsEntered() {
|
||||
val policy = OtpAutoSubmitPolicy()
|
||||
|
||||
assertFalse(policy.shouldSubmit("12345", loading = false))
|
||||
assertTrue(policy.shouldSubmit("123456", loading = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitsSmsRetrieverCodeOnlyOnceWhileItStaysTheSame() {
|
||||
val policy = OtpAutoSubmitPolicy()
|
||||
|
||||
assertTrue(policy.shouldSubmit("987654", loading = false))
|
||||
assertFalse(policy.shouldSubmit("987654", loading = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowsRetryAfterCodeIsEditedBelowSixDigits() {
|
||||
val policy = OtpAutoSubmitPolicy()
|
||||
|
||||
assertTrue(policy.shouldSubmit("111111", loading = false))
|
||||
assertFalse(policy.shouldSubmit("111111", loading = false))
|
||||
assertFalse(policy.shouldSubmit("11111", loading = false))
|
||||
assertTrue(policy.shouldSubmit("111111", loading = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotSubmitWhileLoadingOrForLongerCodes() {
|
||||
val policy = OtpAutoSubmitPolicy()
|
||||
|
||||
assertFalse(policy.shouldSubmit("123456", loading = true))
|
||||
assertFalse(policy.shouldSubmit("1234567", loading = false))
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ datastore = "1.1.1"
|
||||
coil = "2.7.0"
|
||||
camerax = "1.4.1"
|
||||
work = "2.10.0"
|
||||
room = "2.7.0"
|
||||
ksp = "2.1.10-1.0.31"
|
||||
playServicesAuth = "21.6.0"
|
||||
playServicesAuthApiPhone = "18.3.0"
|
||||
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-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-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-core = { group = "androidx.camera", name = "camera-core", 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" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..');
|
||||
|
||||
function readSource(path) {
|
||||
return readFileSync(resolve(root, path), 'utf8');
|
||||
}
|
||||
|
||||
test('driver app sends Android device metadata during OTP verification', () => {
|
||||
const models = readSource('app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt');
|
||||
const repository = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt');
|
||||
const deviceInfo = readSource('app/src/main/java/pl/firmatpp/kierowca/data/DeviceInfoProvider.kt');
|
||||
|
||||
assert.match(models, /data class DriverDeviceInfo/);
|
||||
assert.match(models, /val device: DriverDeviceInfo/);
|
||||
assert.match(repository, /DeviceInfoProvider/);
|
||||
assert.match(repository, /deviceInfoProvider\.currentDeviceInfo/);
|
||||
assert.match(deviceInfo, /Settings\.Secure\.ANDROID_ID/);
|
||||
assert.match(deviceInfo, /Build\.MANUFACTURER/);
|
||||
assert.match(deviceInfo, /Build\.MODEL/);
|
||||
assert.match(deviceInfo, /BuildConfig\.VERSION_NAME/);
|
||||
});
|
||||