Compare commits

...
9 Commits
23 changed files with 1861 additions and 33 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 33 versionCode = 40
versionName = "1.0.32" versionName = "1.0.39"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -13,14 +13,20 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null) private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
setContent { setContent {
TppKierowcaTheme { TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel() val viewModel: DriverViewModel = viewModel()
DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId) DriverApp(
viewModel = viewModel,
initialRouteId = notificationRouteId,
initialLeaveRequestId = notificationLeaveRequestId,
)
} }
} }
} }
@@ -29,9 +35,11 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID)
} }
companion object { companion object {
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID" const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
const val EXTRA_LEAVE_REQUEST_ID = "pl.firmatpp.kierowca.EXTRA_LEAVE_REQUEST_ID"
} }
} }
@@ -14,7 +14,11 @@ import pl.firmatpp.kierowca.data.api.MobileDriverApi
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
@@ -25,6 +29,7 @@ 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.SyncStateResponse import pl.firmatpp.kierowca.data.model.SyncStateResponse
import pl.firmatpp.kierowca.data.model.VerifyOtpBody import pl.firmatpp.kierowca.data.model.VerifyOtpBody
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverRepository( class DriverRepository(
@@ -49,6 +54,18 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse = suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId) api.route(authHeader(requireToken()), routeId)
suspend fun leaveRequests(): List<DriverLeaveRequestDto> =
api.leaveRequests(authHeader(requireToken())).data
suspend fun leaveRequest(id: String): DriverLeaveRequestDto =
api.leaveRequest(authHeader(requireToken()), id).data
suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto =
api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data
suspend fun cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto =
api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).data
suspend fun completeRoute(routeId: String) = suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId) api.completeRoute(authHeader(requireToken()), routeId)
@@ -131,6 +148,32 @@ class DriverRepository(
) )
} }
suspend fun uploadQueuedDispatchSheetPhoto(upload: DispatchSheetUploadEntity): DispatchSheetUploadResponse {
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.uploadDispatchSheetPhoto(
authHeader(requireToken()),
upload.clientRequestId,
photo,
upload.clientRequestId.toPlainTextBody(),
upload.contentSha256.toPlainTextBody(),
upload.source.toPlainTextBody(),
metadataParts["takenAt"],
metadataParts["latitude"],
metadataParts["longitude"],
metadataParts["locationAccuracyMeters"],
upload.replacePhotoId?.toPlainTextBody(),
)
}
suspend fun deletePhoto(photoId: String) { suspend fun deletePhoto(photoId: String) {
api.deletePhoto(authHeader(requireToken()), photoId) api.deletePhoto(authHeader(requireToken()), photoId)
} }
@@ -5,7 +5,13 @@ import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestResponse
import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse
import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
@@ -55,6 +61,35 @@ interface MobileDriverApi {
@Query("date") date: String?, @Query("date") date: String?,
): BootstrapResponse ): BootstrapResponse
@GET("mobile/driver/leave-request-types")
suspend fun leaveRequestTypes(
@Header("Authorization") authorization: String,
): LeaveRequestTypesResponse
@GET("mobile/driver/leave-requests")
suspend fun leaveRequests(
@Header("Authorization") authorization: String,
): LeaveRequestListResponse
@POST("mobile/driver/leave-requests")
suspend fun createLeaveRequest(
@Header("Authorization") authorization: String,
@Body body: CreateLeaveRequestBody,
): LeaveRequestResponse
@GET("mobile/driver/leave-requests/{id}")
suspend fun leaveRequest(
@Header("Authorization") authorization: String,
@Path("id") id: String,
): LeaveRequestResponse
@POST("mobile/driver/leave-requests/{id}/cancel")
suspend fun cancelLeaveRequest(
@Header("Authorization") authorization: String,
@Path("id") id: String,
@Body body: CancelLeaveRequestBody,
): LeaveRequestResponse
@GET("mobile/driver/routes/{routeId}") @GET("mobile/driver/routes/{routeId}")
suspend fun route( suspend fun route(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -113,6 +148,22 @@ interface MobileDriverApi {
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?, @Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
): PhotoUploadResponse ): PhotoUploadResponse
@Multipart
@POST("mobile/driver/dispatch-sheet-photos")
suspend fun uploadDispatchSheetPhoto(
@Header("Authorization") authorization: String,
@Header("Idempotency-Key") idempotencyKey: 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?,
@Part("longitude") longitude: RequestBody?,
@Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?,
@Part("replacePhotoId") replacePhotoId: RequestBody?,
): DispatchSheetUploadResponse
@DELETE("mobile/driver/photos/{photoId}") @DELETE("mobile/driver/photos/{photoId}")
suspend fun deletePhoto( suspend fun deletePhoto(
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@@ -46,7 +46,9 @@ data class BootstrapResponse(
val session: DriverSessionDto, val session: DriverSessionDto,
val routes: RoutesBucketDto, val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?, val driverAppSettings: DriverAppSettingsDto?,
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val notificationPreferences: NotificationPreferencesDto? = null, val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null, val syncState: SyncStateResponse? = null,
) )
@@ -60,6 +62,42 @@ data class DriverAppSettingsDto(
val allowGalleryUploads: Boolean?, val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?, val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?, val requirePreciseLocationForPhotos: Boolean?,
val dispatchSheetRemindersEnabled: Boolean? = null,
val dispatchSheetOnFridays: Boolean? = null,
val dispatchSheetOnLastWorkingDay: Boolean? = null,
val leaveRequests: LeaveRequestsConfigDto? = null,
)
data class LeaveRequestsConfigDto(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
)
data class DispatchSheetReminderDto(
val enabled: Boolean = false,
val dueToday: Boolean = false,
val workDate: String? = null,
val reason: String? = null,
val availableUntil: String? = null,
val status: String = "not_required",
val photo: DispatchSheetPhotoDto? = null,
val canUpload: Boolean = false,
)
data class DispatchSheetPhotoDto(
val id: String,
val driverId: String,
val workDate: String?,
val clientRequestId: String? = null,
val contentSha256: String? = null,
val source: String,
val mimeType: String?,
val size: Long,
val takenAt: String?,
val latitude: Double?,
val longitude: Double?,
val locationAccuracyMeters: Double?,
val createdAt: String?,
) )
data class DriverSessionDto( data class DriverSessionDto(
@@ -71,6 +109,12 @@ data class NotificationPreferencesDto(
val notifyNewRoutes: Boolean = false, val notifyNewRoutes: Boolean = false,
) )
data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
)
data class RoutesBucketDto( data class RoutesBucketDto(
val today: List<DriverRouteDto> = emptyList(), val today: List<DriverRouteDto> = emptyList(),
) )
@@ -119,6 +163,58 @@ data class NotificationPreferencesBody(
val notifyNewRoutes: Boolean, val notifyNewRoutes: Boolean,
) )
data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(),
)
data class LeaveRequestResponse(
val data: DriverLeaveRequestDto,
)
data class LeaveRequestTypesResponse(
val data: List<LeaveRequestTypeDto> = emptyList(),
)
data class LeaveRequestTypeDto(
val value: String,
val label: String,
)
data class CreateLeaveRequestBody(
val dateFrom: String,
val dateTo: String,
val type: String,
val note: String?,
)
data class CancelLeaveRequestBody(
val comment: String? = null,
)
data class DriverLeaveRequestDto(
val id: String,
val driver: DriverDto? = null,
val dateFrom: String?,
val dateTo: String?,
val type: String,
val typeLabel: String? = null,
val note: String? = null,
val status: String,
val submittedAt: String? = null,
val decidedAt: String? = null,
val decisionComment: String? = null,
val events: List<DriverLeaveRequestEventDto> = emptyList(),
)
data class DriverLeaveRequestEventDto(
val id: String,
val action: String,
val fromStatus: String? = null,
val toStatus: String? = null,
val comment: String? = null,
val createdAt: String? = null,
)
data class RealtimeStatusBody( data class RealtimeStatusBody(
val reverbStatus: String, val reverbStatus: String,
val socketId: String? = null, val socketId: String? = null,
@@ -189,3 +285,16 @@ data class PhotoUploadReceiptDto(
val contentSha256: String, val contentSha256: String,
val storedAt: String?, val storedAt: String?,
) )
data class DispatchSheetUploadResponse(
val photo: DispatchSheetPhotoDto,
val reminder: DispatchSheetReminderDto,
val receipt: DispatchSheetUploadReceiptDto,
)
data class DispatchSheetUploadReceiptDto(
val clientRequestId: String,
val serverPhotoId: String,
val contentSha256: String,
val storedAt: String?,
)
@@ -150,5 +150,7 @@ class DriverSyncRepository(
const val SCOPE_ROUTES = "routes" const val SCOPE_ROUTES = "routes"
const val SCOPE_ROUTE_DETAIL = "route_detail" const val SCOPE_ROUTE_DETAIL = "route_detail"
const val SCOPE_SETTINGS = "settings" const val SCOPE_SETTINGS = "settings"
const val SCOPE_DISPATCH_SHEET = "dispatch_sheet"
const val SCOPE_LEAVE_REQUESTS = "leave_requests"
} }
} }
@@ -0,0 +1,66 @@
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 DispatchSheetUploadDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(upload: DispatchSheetUploadEntity)
@Query("SELECT * FROM dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId LIMIT 1")
suspend fun find(clientRequestId: String): DispatchSheetUploadEntity?
@Query(
"""
SELECT * FROM dispatch_sheet_uploads
WHERE workDate = :workDate
AND status != 'CANCELLED'
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>>
@Query(
"""
UPDATE dispatch_sheet_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 dispatch_sheet_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 dispatch_sheet_uploads WHERE clientRequestId = :clientRequestId")
suspend fun delete(clientRequestId: String)
}
@@ -0,0 +1,37 @@
package pl.firmatpp.kierowca.data.upload
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "dispatch_sheet_uploads",
indices = [
Index(value = ["workDate"]),
Index(value = ["status"]),
],
)
data class DispatchSheetUploadEntity(
@PrimaryKey val clientRequestId: String,
val workDate: String,
val replacePhotoId: 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,102 @@
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
class DispatchSheetUploadOutbox(
private val context: Context,
private val dao: DispatchSheetUploadDao = DriverDatabase.get(context).dispatchSheetUploadDao(),
private val workManager: WorkManager = WorkManager.getInstance(context),
) {
fun observeVisibleForWorkDate(workDate: String): Flow<List<DispatchSheetUploadEntity>> =
dao.observeVisibleForWorkDate(workDate)
suspend fun enqueue(
workDate: String,
replacePhotoId: String?,
uri: Uri,
source: String,
metadata: PhotoUploadMetadata,
): DispatchSheetUploadEntity {
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, "dispatch-sheet-upload-outbox").apply { mkdirs() }
val localFile = File(uploadDir, "$clientRequestId.$extension")
val sha256 = copyAndHash(uri, localFile)
val upload = DispatchSheetUploadEntity(
clientRequestId = clientRequestId,
workDate = workDate,
replacePhotoId = replacePhotoId,
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 discard(clientRequestId: String) {
dao.find(clientRequestId)?.let { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
}
private fun enqueueWorker(clientRequestId: String) {
val request = OneTimeWorkRequestBuilder<DispatchSheetUploadWorker>()
.setInputData(workDataOf(DispatchSheetUploadWorker.KEY_CLIENT_REQUEST_ID to clientRequestId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
workManager.enqueueUniqueWork(
DispatchSheetUploadWorker.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 karty spedycyjnej." }
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,10 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.model.DispatchSheetUploadReceiptDto
object DispatchSheetUploadReceiptVerifier {
fun matches(upload: DispatchSheetUploadEntity, receipt: DispatchSheetUploadReceiptDto): Boolean =
upload.clientRequestId == receipt.clientRequestId &&
upload.contentSha256.equals(receipt.contentSha256, ignoreCase = true) &&
receipt.serverPhotoId.isNotBlank()
}
@@ -0,0 +1,86 @@
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 DispatchSheetUploadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).dispatchSheetUploadDao()
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()) {
val message = "Lokalny plik zdjęcia karty spedycyjnej nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = PhotoUploadFailureDetails.local(message),
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.uploadQueuedDispatchSheetPhoto(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 (!DispatchSheetUploadReceiptVerifier.matches(upload, receipt)) {
error("Serwer nie potwierdził zgodności zdjęcia karty spedycyjnej. 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 = PhotoUploadFailureDetails.fromApiError(error),
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 = "dispatch-sheet-upload-$clientRequestId"
}
}
@@ -14,15 +14,17 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncStateEntity
@Database( @Database(
entities = [ entities = [
PhotoUploadEntity::class, PhotoUploadEntity::class,
DispatchSheetUploadEntity::class,
DriverBootstrapCacheEntity::class, DriverBootstrapCacheEntity::class,
DriverRouteCacheEntity::class, DriverRouteCacheEntity::class,
DriverSyncStateEntity::class, DriverSyncStateEntity::class,
], ],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class DriverDatabase : RoomDatabase() { abstract class DriverDatabase : RoomDatabase() {
abstract fun photoUploadDao(): PhotoUploadDao abstract fun photoUploadDao(): PhotoUploadDao
abstract fun dispatchSheetUploadDao(): DispatchSheetUploadDao
abstract fun driverCacheDao(): DriverCacheDao abstract fun driverCacheDao(): DriverCacheDao
companion object { companion object {
@@ -71,13 +73,45 @@ abstract class DriverDatabase : RoomDatabase() {
} }
} }
private val migration2To3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS dispatch_sheet_uploads (
clientRequestId TEXT NOT NULL PRIMARY KEY,
workDate TEXT NOT NULL,
replacePhotoId TEXT,
localPath TEXT NOT NULL,
source TEXT NOT NULL,
takenAt TEXT,
latitude REAL,
longitude REAL,
locationAccuracyMeters REAL,
mimeType TEXT NOT NULL,
size INTEGER NOT NULL,
contentSha256 TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL,
attemptCount INTEGER NOT NULL,
lastError TEXT,
serverPhotoId TEXT,
createdAtEpochMillis INTEGER NOT NULL,
updatedAtEpochMillis INTEGER NOT NULL
)
""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_workDate ON dispatch_sheet_uploads(workDate)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_dispatch_sheet_uploads_status ON dispatch_sheet_uploads(status)")
}
}
fun get(context: Context): DriverDatabase = fun get(context: Context): DriverDatabase =
instance ?: synchronized(this) { instance ?: synchronized(this) {
instance ?: Room.databaseBuilder( instance ?: Room.databaseBuilder(
context.applicationContext, context.applicationContext,
DriverDatabase::class.java, DriverDatabase::class.java,
"driver-local-outbox.db", "driver-local-outbox.db",
).addMigrations(migration1To2).build().also { instance = it } ).addMigrations(migration1To2, migration2To3).build().also { instance = it }
} }
} }
} }
@@ -31,6 +31,21 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
return return
} }
if (data["type"] == "driver_leave_request_decision") {
LeaveRequestDecisionNotificationWorker.enqueue(
context = applicationContext,
leaveRequestId = data["leaveRequestId"],
title = data["title"],
body = data["body"],
)
DriverSyncWorker.enqueue(
context = applicationContext,
date = data["date"],
routeId = data["routeId"],
)
return
}
if (data["type"] != "driver_sync_hint") return if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue( DriverSyncWorker.enqueue(
@@ -15,8 +15,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
class DriverLiveSyncClient( class DriverLiveSyncClient(
private val repository: DriverRepository, private val repository: DriverRepository,
@@ -25,20 +25,29 @@ class DriverLiveSyncClient(
private val client: OkHttpClient = OkHttpClient(), private val client: OkHttpClient = OkHttpClient(),
private val gson: Gson = Gson(), private val gson: Gson = Gson(),
) { ) {
private companion object {
const val HEARTBEAT_INTERVAL_MS = 10_000L
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private var webSocket: WebSocket? = null private var webSocket: WebSocket? = null
private var driverId: String? = null private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null private var socketId: String? = null
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
fun start(driverId: String) { fun start(driverId: String, config: RealtimeConfigDto?) {
if (BuildConfig.REVERB_APP_KEY.isBlank()) return val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
if (!config.reverbEnabled) return
realtimeConfig = config
this.driverId = driverId this.driverId = driverId
if (!started.compareAndSet(false, true)) return if (!started.compareAndSet(false, true)) return
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') + val wsUrl = wsBaseUrl.trimEnd('/') +
"/" + BuildConfig.REVERB_APP_KEY + "/" + appKey +
"?protocol=7&client=android&version=1.0&flash=false" "?protocol=7&client=android&version=1.0&flash=false"
webSocket = client.newWebSocket( webSocket = client.newWebSocket(
@@ -72,6 +81,7 @@ class DriverLiveSyncClient(
webSocket?.close(1000, "logout") webSocket?.close(1000, "logout")
webSocket = null webSocket = null
driverId = null driverId = null
realtimeConfig = null
socketId = null socketId = null
} }
@@ -146,7 +156,7 @@ class DriverLiveSyncClient(
scope.launch { scope.launch {
delay(5_000) delay(5_000)
if (!started.get() && driverId == id) { if (!started.get() && driverId == id) {
start(id) start(id, realtimeConfig)
} }
} }
} }
@@ -156,7 +166,7 @@ class DriverLiveSyncClient(
heartbeatJob = scope.launch { heartbeatJob = scope.launch {
while (started.get()) { while (started.get()) {
reportRealtimeStatus("connected") reportRealtimeStatus("connected")
delay(30_000) delay(HEARTBEAT_INTERVAL_MS)
} }
} }
} }
@@ -42,6 +42,15 @@ class DriverSyncWorker(
refreshed = true refreshed = true
} }
} }
DriverSyncRepository.SCOPE_SETTINGS,
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> {
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
} }
} }
} }
@@ -0,0 +1,110 @@
package pl.firmatpp.kierowca.sync
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import pl.firmatpp.kierowca.MainActivity
import pl.firmatpp.kierowca.R
class LeaveRequestDecisionNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val leaveRequestId = inputData.getString(KEY_LEAVE_REQUEST_ID)?.takeIf(String::isNotBlank)
?: return Result.success()
if (!canShowNotifications(applicationContext)) {
return Result.success()
}
createChannel(applicationContext)
val title = inputData.getString(KEY_TITLE)?.takeIf(String::isNotBlank) ?: "Decyzja w sprawie urlopu"
val body = inputData.getString(KEY_BODY)?.takeIf(String::isNotBlank) ?: "Status wniosku urlopowego został zmieniony."
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_LEAVE_REQUEST_ID, leaveRequestId)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
leaveRequestId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification)
return Result.success()
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"Wnioski urlopowe",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Powiadomienia o decyzjach w sprawie wniosków urlopowych."
}
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
}
private fun canShowNotifications(context: Context): Boolean {
val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled()
}
companion object {
private const val CHANNEL_ID = "leave_request_decisions"
private const val KEY_LEAVE_REQUEST_ID = "leaveRequestId"
private const val KEY_TITLE = "title"
private const val KEY_BODY = "body"
fun enqueue(context: Context, leaveRequestId: String?, title: String?, body: String?) {
if (leaveRequestId.isNullOrBlank()) return
val request = OneTimeWorkRequestBuilder<LeaveRequestDecisionNotificationWorker>()
.setInputData(
workDataOf(
KEY_LEAVE_REQUEST_ID to leaveRequestId,
KEY_TITLE to title,
KEY_BODY to body,
),
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"driver-leave-request-decision-$leaveRequestId",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
@@ -74,6 +74,8 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DateRangePicker
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -86,6 +88,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.material3.rememberDateRangePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -127,14 +130,18 @@ import java.io.File
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
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
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.NavigationPointDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus import pl.firmatpp.kierowca.data.upload.PhotoUploadStatus
import pl.firmatpp.kierowca.domain.OtpCodeExtractor import pl.firmatpp.kierowca.domain.OtpCodeExtractor
@@ -142,7 +149,7 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.ui.theme.TppColors import pl.firmatpp.kierowca.ui.theme.TppColors
@Composable @Composable
fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) { fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initialLeaveRequestId: String? = null) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val context = LocalContext.current val context = LocalContext.current
@@ -190,6 +197,11 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
LaunchedEffect(initialRouteId) { LaunchedEffect(initialRouteId) {
viewModel.openRouteFromNotification(initialRouteId) viewModel.openRouteFromNotification(initialRouteId)
} }
LaunchedEffect(initialLeaveRequestId, state.leaveRequestsConfig) {
if (!initialLeaveRequestId.isNullOrBlank() && DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) {
viewModel.openLeaveRequest(initialLeaveRequestId)
}
}
Box(Modifier.fillMaxSize().background(TppColors.Surface)) { Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
when (state.screen) { when (state.screen) {
@@ -202,13 +214,16 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
onDate = viewModel::selectRouteDate, onDate = viewModel::selectRouteDate,
onProfile = viewModel::openProfile, onProfile = viewModel::openProfile,
onPhotoQueue = viewModel::openPhotoQueue, onPhotoQueue = viewModel::openPhotoQueue,
onDispatchSheetUpload = viewModel::uploadDispatchSheetPhoto,
onRoute = viewModel::openRoute, onRoute = viewModel::openRoute,
onDismissLiveUpdate = viewModel::dismissRouteDayLiveUpdate,
) )
DriverScreen.Profile -> ProfileScreen( DriverScreen.Profile -> ProfileScreen(
state = state, state = state,
onRoutes = viewModel::refreshRoutes, onRoutes = viewModel::refreshRoutes,
onProfile = viewModel::openProfile, onProfile = viewModel::openProfile,
onLogout = viewModel::logout, onLogout = viewModel::logout,
onLeaveRequests = viewModel::openLeaveRequests,
onNewRouteNotificationsChanged = { enabled -> onNewRouteNotificationsChanged = { enabled ->
if (!enabled) { if (!enabled) {
viewModel.updateNewRouteNotifications(false) viewModel.updateNewRouteNotifications(false)
@@ -239,6 +254,24 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
onBack = viewModel::back, onBack = viewModel::back,
onRetryUpload = viewModel::retryPhotoUpload, onRetryUpload = viewModel::retryPhotoUpload,
) )
DriverScreen.LeaveRequests -> LeaveRequestsScreen(
state = state,
onBack = viewModel::back,
onRefresh = viewModel::refreshLeaveRequests,
onAdd = viewModel::openAddLeaveRequest,
onOpen = viewModel::openLeaveRequest,
)
DriverScreen.LeaveRequestDetail -> LeaveRequestDetailScreen(
state = state,
onBack = viewModel::back,
onCancel = viewModel::cancelSelectedLeaveRequest,
)
DriverScreen.AddLeaveRequest -> AddLeaveRequestScreen(
state = state,
onBack = viewModel::back,
onDraft = viewModel::updateLeaveRequestDraft,
onSubmit = viewModel::submitLeaveRequest,
)
} }
if (state.loading && state.screen != DriverScreen.Initializing) { if (state.loading && state.screen != DriverScreen.Initializing) {
@@ -545,8 +578,47 @@ private fun RoutesScreen(
onDate: (String) -> Unit, onDate: (String) -> Unit,
onProfile: () -> Unit, onProfile: () -> Unit,
onPhotoQueue: () -> Unit, onPhotoQueue: () -> Unit,
onDispatchSheetUpload: (Uri, String, PhotoUploadMetadata) -> Unit,
onRoute: (String) -> Unit, onRoute: (String) -> Unit,
onDismissLiveUpdate: () -> Unit,
) { ) {
val context = LocalContext.current
var cameraUri by remember { mutableStateOf<Uri?>(null) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok ->
val capturedUri = cameraUri
if (
ok &&
capturedUri != null &&
canLaunchCameraWithLocationPolicy(
requirePreciseLocation = state.requirePreciseLocationForPhotos,
hasFineLocation = hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION),
)
) {
onDispatchSheetUpload(capturedUri, "camera", cameraPhotoMetadata(context))
}
cameraUri = null
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val pendingUri = cameraUri
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
showPreciseLocationPermissionDialog = true
cameraUri = null
return@rememberLauncherForActivityResult
}
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri)
}
LaunchedEffect(state.routeDayLiveUpdateMessage) {
if (!state.routeDayLiveUpdateMessage.isNullOrBlank()) {
delay(6_000)
onDismissLiveUpdate()
}
}
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
val screenWidthDp = maxWidth.value.toInt() val screenWidthDp = maxWidth.value.toInt()
val screenHeightDp = maxHeight.value.toInt() val screenHeightDp = maxHeight.value.toInt()
@@ -554,7 +626,17 @@ private fun RoutesScreen(
val compactWidth = screenWidthDp < 360 val compactWidth = screenWidthDp < 360
Scaffold( Scaffold(
topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) }, topBar = {
Box(Modifier.fillMaxWidth()) {
StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp)
RouteDayLiveUpdateBanner(
message = state.routeDayLiveUpdateMessage,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = horizontalPadding, vertical = 10.dp),
)
}
},
bottomBar = { bottomBar = {
StitchBottomBar( StitchBottomBar(
activeScreen = DriverScreen.Routes, activeScreen = DriverScreen.Routes,
@@ -607,6 +689,27 @@ private fun RoutesScreen(
onDate = onDate, onDate = onDate,
) )
} }
if (shouldShowDispatchSheetReminderCard(state.dispatchSheetReminder)) {
item {
DispatchSheetReminderCard(
reminder = state.dispatchSheetReminder,
uploads = state.dispatchSheetUploads,
onCamera = {
val newUri = createCameraUri(context)
cameraUri = newUri
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
cameraLauncher.launch(newUri)
} else {
cameraPermissionLauncher.launch(missingPermissions)
}
},
)
}
}
item { OfflineStaleBanner(state) } item { OfflineStaleBanner(state) }
item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) } item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
if (state.routes.isEmpty()) { if (state.routes.isEmpty()) {
@@ -626,6 +729,557 @@ private fun RoutesScreen(
} }
} }
} }
if (showPreciseLocationPermissionDialog) {
AlertDialog(
onDismissRequest = { showPreciseLocationPermissionDialog = false },
title = { Text("Brak dokładnej lokalizacji", color = TppColors.Ink, fontWeight = FontWeight.Bold) },
text = {
Text(
"Aby zrobić zdjęcie, nadaj aplikacji uprawnienie do dokładnej lokalizacji.",
color = TppColors.Muted,
)
},
confirmButton = {
TextButton(
onClick = {
showPreciseLocationPermissionDialog = false
openAppSettings(context)
},
) {
Text("Przejdź do ustawień", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showPreciseLocationPermissionDialog = false }) {
Text("Anuluj", color = TppColors.Muted)
}
},
)
}
}
@Composable
private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modifier) {
if (message.isNullOrBlank()) return
Card(
colors = CardDefaults.cardColors(containerColor = Color(0xFFEAF7EF)),
border = BorderStroke(1.dp, Color(0xFF9BD1AD)),
shape = RoundedCornerShape(8.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(40.dp).background(Color.White, RoundedCornerShape(6.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Refresh, contentDescription = null, tint = TppColors.Forest)
}
Text(
message,
color = TppColors.Forest,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun LeaveRequestsEntryCard(requests: List<DriverLeaveRequestDto>, onOpen: () -> Unit) {
val decisionCount = requests.count { it.status == "pending" || it.status == "cancel_requested" }
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(44.dp).background(Color(0xFFEAF7EF), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text("Wnioski urlopowe", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text(
if (decisionCount > 0) "$decisionCount czeka na decyzję" else "Lista, status i nowy wniosek",
color = TppColors.Muted,
fontSize = 14.sp,
)
}
Text("Otwórz", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun LeaveRequestsScreen(
state: DriverUiState,
onBack: () -> Unit,
onRefresh: () -> Unit,
onAdd: () -> Unit,
onOpen: (String) -> Unit,
) {
val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
Scaffold(
topBar = { SimpleTopBar("Wnioski urlopowe", onBack) },
containerColor = TppColors.Surface,
) { padding ->
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { FeedbackAndError(state.feedback, state.error) }
item {
Button(
onClick = onAdd,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(8.dp),
) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Nowy wniosek", fontWeight = FontWeight.Bold)
}
}
if (state.leaveRequests.isEmpty()) {
item { EmptyState("Nie masz jeszcze wniosków urlopowych") }
} else {
items(state.leaveRequests, key = { it.id }) { request ->
LeaveRequestCard(request, onClick = { onOpen(request.id) })
}
}
}
PullRefreshIndicator(
refreshing = state.refreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = Color.White,
contentColor = TppColors.Forest,
)
}
}
}
@Composable
private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(
"${DriverLeaveRequestUiRules.typeLabel(request.type)} · ${leaveDateRange(request.dateFrom, request.dateTo)}",
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
)
if (!request.note.isNullOrBlank()) {
Text(request.note, color = TppColors.Muted, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
LeaveStatusPill(request.status)
}
Text("Szczegóły", color = TppColors.Forest, fontWeight = FontWeight.Bold, fontSize = 14.sp)
}
}
}
@Composable
private fun LeaveRequestDetailScreen(
state: DriverUiState,
onBack: () -> Unit,
onCancel: () -> Unit,
) {
val request = state.selectedLeaveRequest
Scaffold(topBar = { SimpleTopBar("Szczegóły wniosku", onBack) }, containerColor = TppColors.Surface) { padding ->
if (request == null) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text("Nie znaleziono wniosku", color = TppColors.Muted)
}
return@Scaffold
}
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item { FeedbackAndError(state.feedback, state.error) }
item {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) {
Text(DriverLeaveRequestUiRules.typeLabel(request.type), color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 22.sp)
LeaveStatusPill(request.status)
}
DetailLine("Zakres", leaveDateRange(request.dateFrom, request.dateTo))
DetailLine("Złożono", request.submittedAt?.let(::shortDateTime) ?: "-")
DetailLine("Decyzja", request.decidedAt?.let(::shortDateTime) ?: "-")
DetailLine("Notatka", request.note ?: "-")
DetailLine("Komentarz", request.decisionComment ?: "-")
if (DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) {
Button(
onClick = onCancel,
modifier = Modifier.fillMaxWidth().height(54.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)),
shape = RoundedCornerShape(8.dp),
) {
Text(if (request.status == "approved") "Poproś o anulowanie" else "Anuluj wniosek")
}
}
}
}
}
item {
Text("Historia", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp)
}
items(request.events, key = { it.id }) { event ->
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.65f)),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
"${event.action}${event.toStatus?.let { " · ${DriverLeaveRequestUiRules.statusLabel(it)}" } ?: ""}",
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
)
Text(event.createdAt?.let(::shortDateTime) ?: "-", color = TppColors.Muted, fontSize = 13.sp)
if (!event.comment.isNullOrBlank()) Text(event.comment, color = TppColors.Muted)
}
}
}
}
}
}
@Composable
private fun AddLeaveRequestScreen(
state: DriverUiState,
onBack: () -> Unit,
onDraft: (String?, String?, String?, String?) -> Unit,
onSubmit: () -> Unit,
) {
var showDateRangePicker by remember { mutableStateOf(false) }
val selectedStartMillis = remember(state.leaveRequestDateFrom) {
localDateStringToUtcMillis(state.leaveRequestDateFrom)
}
val selectedEndMillis = remember(state.leaveRequestDateTo) {
localDateStringToUtcMillis(state.leaveRequestDateTo)
}
val dateRangePickerState = rememberDateRangePickerState(
initialSelectedStartDateMillis = selectedStartMillis,
initialSelectedEndDateMillis = selectedEndMillis,
)
Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppColors.Surface) { padding ->
LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { FeedbackAndError(null, state.error) }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Typ", color = TppColors.Ink, fontWeight = FontWeight.Bold)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.leaveRequestsConfig?.types.orEmpty()) { type ->
val active = type == state.leaveRequestType
Button(
onClick = { onDraft(null, null, type, null) },
colors = ButtonDefaults.buttonColors(
containerColor = if (active) TppColors.Forest else Color.White,
contentColor = if (active) Color.White else TppColors.Ink,
),
border = BorderStroke(1.dp, if (active) TppColors.Forest else TppColors.Outline),
shape = RoundedCornerShape(8.dp),
) {
Text(DriverLeaveRequestUiRules.typeLabel(type))
}
}
}
}
}
item {
Card(
colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppColors.Outline),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth().clickable { showDateRangePicker = true },
) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(44.dp).background(Color(0xFFEAF7EF), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Okres", color = TppColors.Ink, fontWeight = FontWeight.Bold)
Text(
leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo),
color = TppColors.Muted,
)
}
Text("Zmień", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
}
}
item {
LeaveTextField(
label = "Notatka (opcjonalnie)",
value = state.leaveRequestNote,
onValue = { onDraft(null, null, null, it) },
minLines = 4,
)
}
item {
Button(
onClick = onSubmit,
modifier = Modifier.fillMaxWidth().height(58.dp),
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
shape = RoundedCornerShape(8.dp),
) {
Text("Wyślij wniosek", fontWeight = FontWeight.Bold)
}
}
}
}
if (showDateRangePicker) {
DatePickerDialog(
onDismissRequest = { showDateRangePicker = false },
confirmButton = {
TextButton(
onClick = {
val start = dateRangePickerState.selectedStartDateMillis
val end = dateRangePickerState.selectedEndDateMillis ?: start
if (start != null && end != null) {
onDraft(utcMillisToLocalDateString(start), utcMillisToLocalDateString(end), null, null)
}
showDateRangePicker = false
},
enabled = dateRangePickerState.selectedStartDateMillis != null,
) {
Text("Ustaw", color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
},
dismissButton = {
TextButton(onClick = { showDateRangePicker = false }) {
Text("Anuluj", color = TppColors.Muted)
}
},
) {
DateRangePicker(
state = dateRangePickerState,
title = {
Text(
"Wybierz okres urlopu",
modifier = Modifier.padding(start = 24.dp, end = 12.dp, top = 16.dp),
color = TppColors.Ink,
fontWeight = FontWeight.Bold,
)
},
headline = {
Text(
leaveDateRange(
dateRangePickerState.selectedStartDateMillis?.let(::utcMillisToLocalDateString),
dateRangePickerState.selectedEndDateMillis?.let(::utcMillisToLocalDateString),
),
modifier = Modifier.padding(start = 24.dp, end = 12.dp, bottom = 12.dp),
color = TppColors.Muted,
)
},
showModeToggle = false,
)
}
}
}
@Composable
private fun SimpleTopBar(title: String, onBack: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.statusBarsPadding()
.background(Color.White)
.border(0.5.dp, TppColors.Outline.copy(alpha = 0.65f))
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink)
}
Text(title, color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 20.sp)
}
}
@Composable
private fun LeaveStatusPill(status: String) {
val color = when (status) {
"approved" -> Color(0xFFEAF7EF) to TppColors.Forest
"rejected" -> Color(0xFFFFEBEE) to Color(0xFFB91C1C)
"cancel_requested" -> Color(0xFFFFF7ED) to Color(0xFFB45309)
"cancelled", "revoked" -> Color(0xFFF1F5F9) to Color(0xFF475569)
else -> Color(0xFFFFF8DB) to Color(0xFF7A5A00)
}
Text(
DriverLeaveRequestUiRules.statusLabel(status),
color = color.second,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
modifier = Modifier.background(color.first, RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 6.dp),
)
}
@Composable
private fun DetailLine(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(label.uppercase(Locale("pl", "PL")), color = TppColors.Muted, fontSize = 12.sp, fontWeight = FontWeight.Bold)
Text(value, color = TppColors.Ink, fontSize = 16.sp)
}
}
@Composable
private fun FeedbackAndError(feedback: String?, error: String?) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!feedback.isNullOrBlank()) {
Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold)
}
ErrorText(error)
}
}
@Composable
private fun LeaveTextField(label: String, value: String, onValue: (String) -> Unit, minLines: Int = 1) {
OutlinedTextField(
value = value,
onValueChange = onValue,
label = { Text(label) },
minLines = minLines,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
)
}
private fun leaveDateRange(from: String?, to: String?): String =
if (from == to) from ?: "-" else "${from ?: "?"} - ${to ?: "?"}"
private fun localDateStringToUtcMillis(value: String): Long? =
runCatching {
LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
}.getOrNull()
private fun utcMillisToLocalDateString(value: Long): String =
Instant.ofEpochMilli(value).atZone(ZoneOffset.UTC).toLocalDate().toString()
private fun shortDateTime(value: String): String =
runCatching {
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale("pl", "PL"))
.format(Instant.parse(value).atZone(ZoneId.systemDefault()))
}.getOrDefault(value)
@Composable
private fun DispatchSheetReminderCard(
reminder: DispatchSheetReminderDto?,
uploads: List<DispatchSheetUploadEntity>,
onCamera: () -> Unit,
) {
if (!shouldShowDispatchSheetReminderCard(reminder)) return
val queuedCount = queuedPhotoUploadCount(uploads.map { it.status })
val hasQueuedUpload = queuedCount > 0
val uploaded = reminder?.status == "uploaded" && !hasQueuedUpload
val container = if (uploaded) Color(0xFFEAF7EF) else Color.White
val border = if (uploaded) Color(0xFF9BD1AD) else TppColors.Outline
val actionLabel = dispatchSheetPrimaryActionLabel(reminder, hasQueuedUpload)
Card(
colors = CardDefaults.cardColors(containerColor = container),
border = BorderStroke(1.dp, border),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
if (uploaded) Icons.Outlined.CheckCircle else Icons.Outlined.CameraAlt,
contentDescription = null,
tint = if (uploaded) TppColors.Forest else TppColors.Ink,
modifier = Modifier.size(28.dp),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
if (uploaded) "Zdjęcie karty spedycyjnej wykonane" else "Karta spedycyjna",
color = TppColors.Ink,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
if (uploaded) {
"Dokument został zapisany. W razie potrzeby możesz zrobić poprawione zdjęcie."
} else {
"Po zakończeniu dnia zrób zdjęcie karty spedycyjnej."
},
color = TppColors.Muted,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Button(
onClick = onCamera,
enabled = reminder?.canUpload == true && !hasQueuedUpload,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (uploaded) TppColors.ContainerGreen else TppColors.Forest,
disabledContainerColor = TppColors.Outline,
),
shape = RoundedCornerShape(8.dp),
) {
if (hasQueuedUpload) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Outlined.CameraAlt, contentDescription = null, tint = Color.White)
}
Spacer(Modifier.width(10.dp))
Text(actionLabel, color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
} }
@Composable @Composable
@@ -1008,6 +1662,7 @@ private fun ProfileScreen(
onRoutes: () -> Unit, onRoutes: () -> Unit,
onProfile: () -> Unit, onProfile: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onLeaveRequests: () -> Unit,
onNewRouteNotificationsChanged: (Boolean) -> Unit, onNewRouteNotificationsChanged: (Boolean) -> Unit,
onOpenNotificationSettings: () -> Unit, onOpenNotificationSettings: () -> Unit,
) { ) {
@@ -1052,6 +1707,9 @@ private fun ProfileScreen(
) )
} }
} }
if (DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) {
LeaveRequestsEntryCard(state.leaveRequests, onLeaveRequests)
}
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
@@ -1238,7 +1896,7 @@ private fun DetailScreen(
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp), contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) } item { ManifestSection(route, selectedDate = state.selectedDate, onNavigate = { openNavigation(context, it) }) }
item { OfflineStaleBanner(state) } item { OfflineStaleBanner(state) }
item { item {
RouteCompletionSection( RouteCompletionSection(
@@ -1456,8 +2114,9 @@ private fun photoCountLabel(count: Int): String =
} }
@Composable @Composable
private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) { private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
val stripColor = routeStatusColor(route.status) val stripColor = routeStatusColor(route.status)
val routeDate = route.routeDate ?: selectedDate
Card( Card(
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@@ -1465,13 +2124,17 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
) { ) {
Column { Column {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
StatusChip(route.status, stripColor) StatusChip(route.status, stripColor)
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
RouteDateStatusChip(routeDate)
}
}
Text( Text(
"Zlecenie #${route.contractCode ?: route.id}", "Zlecenie #${route.contractCode ?: route.id}",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -1480,7 +2143,6 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
) )
} }
} }
}
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f)) HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
RoutePointBlock( RoutePointBlock(
label = "Załadunek", label = "Załadunek",
@@ -1509,6 +2171,32 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
} }
} }
@Composable
private fun RouteDateStatusChip(routeDate: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(Color(0xFFE9F2FF), RoundedCornerShape(3.dp))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Outlined.CalendarToday,
contentDescription = null,
tint = Color(0xFF1D4E89),
modifier = Modifier.size(16.dp),
)
Text(
routeDateChipLabel(routeDate),
color = Color(0xFF1D4E89),
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable @Composable
private fun RoutePointBlock( private fun RoutePointBlock(
label: String, label: String,
@@ -0,0 +1,42 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
data class LeaveRequestsConfig(
val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"),
)
object DriverLeaveRequestUiRules {
fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean =
config?.enabled == true && config.types.isNotEmpty()
fun statusLabel(status: String): String =
when (status) {
"pending" -> "Oczekuje"
"approved" -> "Zatwierdzony"
"rejected" -> "Odrzucony"
"cancel_requested" -> "Anulowanie do decyzji"
"cancelled" -> "Anulowany"
"revoked" -> "Cofnięto decyzję"
else -> status
}
fun typeLabel(type: String): String =
when (type) {
"URLOP" -> "Urlop"
"CHOROBOWE" -> "Chorobowe"
"SZKOLENIE" -> "Szkolenie"
"WOLNE" -> "Dzień wolny"
"INNE" -> "Inne"
else -> type
}
fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean {
if (status == "pending") return true
if (status != "approved") return false
val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false
return start.isAfter(today)
}
}
@@ -1,12 +1,18 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import java.io.File import java.io.File
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
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)
@@ -18,6 +24,38 @@ fun canCompleteRouteFromDriverApp(
canManageRoutePhotos(selectedDate, today) canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE") && route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
fun routeDateChipLabel(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val date = routeDate
?.takeIf { it.isNotBlank() }
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
?: return ""
val fallback = date.format(shortDateFormatter)
val days = ChronoUnit.DAYS.between(today, date)
val human = when {
days == 0L -> "dzisiaj"
days == 1L -> "jutro"
days in 2L..7L -> polishWeekdayPhrase(date.dayOfWeek)
else -> null
}
return human?.let { "$it · $fallback" } ?: fallback
}
private fun polishWeekdayPhrase(day: DayOfWeek): String =
when (day) {
DayOfWeek.MONDAY -> "w poniedziałek"
DayOfWeek.TUESDAY -> "we wtorek"
DayOfWeek.WEDNESDAY -> "w środę"
DayOfWeek.THURSDAY -> "w czwartek"
DayOfWeek.FRIDAY -> "w piątek"
DayOfWeek.SATURDAY -> "w sobotę"
DayOfWeek.SUNDAY -> "w niedzielę"
}
fun inlinePhotoGridRows(photoCount: Int): Int = fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2 if (photoCount <= 0) 0 else (photoCount + 1) / 2
@@ -104,6 +142,16 @@ fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean = fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation !requirePreciseLocation || hasFineLocation
fun shouldShowDispatchSheetReminderCard(reminder: DispatchSheetReminderDto?): Boolean =
reminder?.dueToday == true && reminder.status in setOf("missing", "uploaded")
fun dispatchSheetPrimaryActionLabel(reminder: DispatchSheetReminderDto?, hasQueuedUpload: Boolean): String =
when {
hasQueuedUpload -> "Wysyłam zdjęcie..."
reminder?.status == "uploaded" -> "Popraw"
else -> "Zrób zdjęcie"
}
private fun parseIsoOffsetEpochMillis(value: String?): Long? = private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let { value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
@@ -17,16 +17,20 @@ import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.PhotoUploadMetadata
import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.DriverSyncRepository
import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.NetworkMonitor
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity
import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadOutbox
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox import pl.firmatpp.kierowca.data.upload.PhotoUploadOutbox
import pl.firmatpp.kierowca.sync.DriverLiveSyncClient import pl.firmatpp.kierowca.sync.DriverLiveSyncClient
import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncHint
import pl.firmatpp.kierowca.sync.DriverSyncWorker import pl.firmatpp.kierowca.sync.DriverSyncWorker
enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue } enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest }
data class DriverUiState( data class DriverUiState(
val screen: DriverScreen = DriverScreen.Initializing, val screen: DriverScreen = DriverScreen.Initializing,
@@ -50,12 +54,22 @@ data class DriverUiState(
val selectedPhoto: RoutePhotoDto? = null, val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(), val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(), val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = emptyList(),
val selectedLeaveRequest: DriverLeaveRequestDto? = null,
val leaveRequestDateFrom: String = LocalDate.now().toString(),
val leaveRequestDateTo: String = LocalDate.now().toString(),
val leaveRequestType: String = "URLOP",
val leaveRequestNote: String = "",
val deletingPhotoIds: Set<String> = emptySet(), val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false, val completingRoute: Boolean = false,
val imageAuthHeader: String? = null, val imageAuthHeader: String? = null,
val isOnline: Boolean = true, val isOnline: Boolean = true,
val isStale: Boolean = false, val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null, val lastSuccessfulSyncAtEpochMillis: Long? = null,
val routeDayLiveUpdateMessage: String? = null,
val feedback: String? = null, val feedback: String? = null,
val error: String? = null, val error: String? = null,
) )
@@ -65,6 +79,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private val syncRepository = DriverSyncRepository(application, repository) private val syncRepository = DriverSyncRepository(application, repository)
private val networkMonitor = NetworkMonitor(application) private val networkMonitor = NetworkMonitor(application)
private val photoUploadOutbox = PhotoUploadOutbox(application) private val photoUploadOutbox = PhotoUploadOutbox(application)
private val dispatchSheetUploadOutbox = DispatchSheetUploadOutbox(application)
private val _state = MutableStateFlow(DriverUiState(loading = true)) private val _state = MutableStateFlow(DriverUiState(loading = true))
private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy() private val otpAutoSubmitPolicy = OtpAutoSubmitPolicy()
private val liveSyncClient = DriverLiveSyncClient( private val liveSyncClient = DriverLiveSyncClient(
@@ -73,6 +88,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } }, onHint = { hint -> viewModelScope.launch { handleSyncHint(hint) } },
) )
private var photoUploadsJob: Job? = null private var photoUploadsJob: Job? = null
private var dispatchSheetUploadsJob: Job? = null
private var pushTokenRegisteredForDriverId: String? = null private var pushTokenRegisteredForDriverId: String? = null
val state: StateFlow<DriverUiState> = _state val state: StateFlow<DriverUiState> = _state
@@ -137,7 +153,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false) fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
fun selectRouteDate(date: String) = loadRoutes(date = date, showLoading = true, navigateToRoutes = true) fun selectRouteDate(date: String) {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
}
fun dismissRouteDayLiveUpdate() {
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
}
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) { private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch { viewModelScope.launch {
@@ -149,9 +172,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val settings = response.driverAppSettings val settings = response.driverAppSettings
_state.update { _state.update {
it.copy( it.copy(
screen = if (navigateToRoutes) DriverScreen.Routes else it.screen,
driver = response.session.driver, driver = response.session.driver,
routes = response.routes.today, routes = response.routes.today,
dispatchSheetReminder = response.dispatchSheetReminder,
selectedDate = settings?.selectedDate ?: date ?: it.selectedDate, selectedDate = settings?.selectedDate ?: date ?: it.selectedDate,
minRouteDate = settings?.minDate ?: it.minRouteDate, minRouteDate = settings?.minDate ?: it.minRouteDate,
maxRouteDate = settings?.maxDate ?: it.maxRouteDate, maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
@@ -160,6 +183,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos, ?: it.requirePreciseLocationForPhotos,
leaveRequestsConfig = settings?.leaveRequests?.let { config ->
LeaveRequestsConfig(enabled = config.enabled, types = config.types)
},
screen = if (
settings?.leaveRequests?.enabled != true &&
it.screen in setOf(DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail, DriverScreen.AddLeaveRequest)
) DriverScreen.Routes else if (navigateToRoutes) DriverScreen.Routes else it.screen,
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes, notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(), imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline, isStale = cached.stale || !it.isOnline,
@@ -168,9 +198,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
) )
} }
response.session.driver.id.let { driverId -> response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId) liveSyncClient.start(driverId, response.realtime)
registerPushTokenIfAvailable(driverId) registerPushTokenIfAvailable(driverId)
} }
observeDispatchSheetUploads(response.dispatchSheetReminder?.workDate)
}.onFailure { throwable -> }.onFailure { throwable ->
_state.update { _state.update {
val apiError = ApiErrorMapper.map(throwable) val apiError = ApiErrorMapper.map(throwable)
@@ -275,6 +306,135 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
} }
fun openLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = true)
}
fun refreshLeaveRequests() {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
loadLeaveRequests(showLoading = false)
}
private fun loadLeaveRequests(showLoading: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) }
runCatching { repository.leaveRequests() }
.onSuccess { requests ->
_state.update {
it.copy(
screen = DriverScreen.LeaveRequests,
leaveRequests = requests,
selectedLeaveRequest = requests.firstOrNull { request -> request.id == it.selectedLeaveRequest?.id } ?: it.selectedLeaveRequest,
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
_state.update { it.copy(loading = false, refreshing = false) }
}
}
fun openLeaveRequest(id: String) {
if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.leaveRequest(id) }
.onSuccess { request ->
_state.update { it.copy(screen = DriverScreen.LeaveRequestDetail, selectedLeaveRequest = request, error = null) }
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
_state.update { it.copy(loading = false) }
}
}
fun openAddLeaveRequest() {
val config = _state.value.leaveRequestsConfig
if (!DriverLeaveRequestUiRules.isFeatureVisible(config)) return
val today = LocalDate.now().toString()
_state.update {
it.copy(
screen = DriverScreen.AddLeaveRequest,
leaveRequestDateFrom = today,
leaveRequestDateTo = today,
leaveRequestType = config?.types?.firstOrNull() ?: "URLOP",
leaveRequestNote = "",
error = null,
feedback = null,
)
}
}
fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) {
_state.update {
val nextFrom = dateFrom ?: it.leaveRequestDateFrom
val nextTo = dateTo ?: it.leaveRequestDateTo
it.copy(
leaveRequestDateFrom = nextFrom,
leaveRequestDateTo = if (nextTo < nextFrom) nextFrom else nextTo,
leaveRequestType = type ?: it.leaveRequestType,
leaveRequestNote = note ?: it.leaveRequestNote,
)
}
}
fun submitLeaveRequest() {
val snapshot = _state.value
if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return
if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) {
_state.update { it.copy(error = "Data od nie może być z przeszłości.") }
return
}
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching {
repository.createLeaveRequest(
dateFrom = snapshot.leaveRequestDateFrom,
dateTo = snapshot.leaveRequestDateTo,
type = snapshot.leaveRequestType,
note = snapshot.leaveRequestNote.takeIf { it.isNotBlank() },
)
}.onSuccess { created ->
val requests = runCatching { repository.leaveRequests() }.getOrElse { listOf(created) }
_state.update {
it.copy(
screen = DriverScreen.LeaveRequestDetail,
selectedLeaveRequest = created,
leaveRequests = requests,
feedback = "Wniosek został wysłany do decyzji.",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(loading = false) }
}
}
fun cancelSelectedLeaveRequest() {
val request = _state.value.selectedLeaveRequest ?: return
if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null, feedback = null) }
runCatching { repository.cancelLeaveRequest(request.id) }
.onSuccess { updated ->
_state.update {
it.copy(
selectedLeaveRequest = updated,
leaveRequests = it.leaveRequests.map { existing -> if (existing.id == updated.id) updated else existing },
feedback = if (updated.status == "cancel_requested") "Anulowanie wysłane do decyzji." else "Wniosek został anulowany.",
error = null,
)
}
}
.onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } }
_state.update { it.copy(loading = false) }
}
}
fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) { fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val route = _state.value.selectedRoute ?: return val route = _state.value.selectedRoute ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -286,6 +446,27 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun uploadDispatchSheetPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) {
val reminder = _state.value.dispatchSheetReminder ?: return
val workDate = reminder.workDate ?: return
if (!reminder.canUpload) return
viewModelScope.launch {
_state.update { it.copy(error = null) }
runCatching {
dispatchSheetUploadOutbox.enqueue(
workDate = workDate,
replacePhotoId = reminder.photo?.id,
uri = uri,
source = source,
metadata = metadata,
)
}.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
}
}
fun retryPhotoUpload(upload: PhotoUploadEntity) { fun retryPhotoUpload(upload: PhotoUploadEntity) {
viewModelScope.launch { viewModelScope.launch {
runCatching { photoUploadOutbox.retry(upload.clientRequestId) } runCatching { photoUploadOutbox.retry(upload.clientRequestId) }
@@ -387,6 +568,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
when (it.screen) { when (it.screen) {
DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes)
DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null)
DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null)
DriverScreen.Detail -> { DriverScreen.Detail -> {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null) it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
@@ -400,6 +584,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
fun logout() = runLoading { fun logout() = runLoading {
photoUploadsJob?.cancel() photoUploadsJob?.cancel()
dispatchSheetUploadsJob?.cancel()
liveSyncClient.stop() liveSyncClient.stop()
repository.logout() repository.logout()
syncRepository.clearCache() syncRepository.clearCache()
@@ -427,6 +612,20 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
private fun observeDispatchSheetUploads(workDate: String?) {
dispatchSheetUploadsJob?.cancel()
if (workDate.isNullOrBlank()) {
_state.update { it.copy(dispatchSheetUploads = emptyList()) }
return
}
dispatchSheetUploadsJob = viewModelScope.launch {
dispatchSheetUploadOutbox.observeVisibleForWorkDate(workDate).collect { uploads ->
_state.update { it.copy(dispatchSheetUploads = uploads) }
}
}
}
private fun refreshCurrentScopeFromSyncState() { private fun refreshCurrentScopeFromSyncState() {
val snapshot = _state.value val snapshot = _state.value
if (snapshot.screen == DriverScreen.Routes) { if (snapshot.screen == DriverScreen.Routes) {
@@ -436,6 +635,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
if (snapshot.screen == DriverScreen.Detail) { if (snapshot.screen == DriverScreen.Detail) {
refreshSelectedRoute() refreshSelectedRoute()
} }
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
} }
private suspend fun checkRemoteSyncState() { private suspend fun checkRemoteSyncState() {
@@ -457,8 +659,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val snapshot = _state.value val snapshot = _state.value
when (hint.scope) { when (hint.scope) {
DriverSyncRepository.SCOPE_ROUTES -> { DriverSyncRepository.SCOPE_ROUTES -> {
if (hint.date == null || hint.date == snapshot.selectedDate) { if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) {
refreshRoutesSilently() refreshRoutesSilently()
if (snapshot.screen == DriverScreen.Routes) {
_state.update {
it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.")
}
}
} else { } else {
DriverSyncWorker.enqueue(getApplication(), hint.date, null) DriverSyncWorker.enqueue(getApplication(), hint.date, null)
} }
@@ -471,6 +678,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
} }
} }
DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently()
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) {
refreshLeaveRequests()
}
}
} }
} }
@@ -491,6 +704,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
override fun onCleared() { override fun onCleared() {
liveSyncClient.close() liveSyncClient.close()
dispatchSheetUploadsJob?.cancel()
super.onCleared() super.onCleared()
} }
} }
@@ -0,0 +1,65 @@
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.DispatchSheetUploadReceiptDto
class DispatchSheetUploadReceiptVerifierTest {
private val upload = DispatchSheetUploadEntity(
clientRequestId = "6f7a7d10-b7f7-41ab-8f5e-f1afc3e93736",
workDate = "2026-07-03",
replacePhotoId = "12",
localPath = "/tmp/dispatch-sheet.jpg",
source = "camera",
takenAt = null,
latitude = null,
longitude = null,
locationAccuracyMeters = null,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
)
@Test
fun confirmsOnlyMatchingReceipt() {
assertTrue(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = upload.contentSha256.uppercase(),
storedAt = "2026-07-03T17:02:00+02:00",
),
),
)
}
@Test
fun rejectsDifferentRequestOrHash() {
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = "other",
serverPhotoId = "55",
contentSha256 = upload.contentSha256,
storedAt = null,
),
),
)
assertFalse(
DispatchSheetUploadReceiptVerifier.matches(
upload,
DispatchSheetUploadReceiptDto(
clientRequestId = upload.clientRequestId,
serverPhotoId = "55",
contentSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
storedAt = null,
),
),
)
}
}
@@ -0,0 +1,34 @@
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
class DriverLeaveRequestUiRulesTest {
@Test
fun featureIsVisibleOnlyWhenServerConfigEnablesIt() {
assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(null))
assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = false, types = listOf("URLOP"))))
assertTrue(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = true, types = listOf("URLOP"))))
}
@Test
fun exposesPolishStatusLabels() {
assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending"))
assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested"))
assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked"))
}
@Test
fun driverCanCancelPendingAndFutureApprovedRequests() {
val tomorrow = LocalDate.now().plusDays(1).toString()
val yesterday = LocalDate.now().minusDays(1).toString()
assertTrue(DriverLeaveRequestUiRules.canCancel(status = "pending", dateFrom = yesterday))
assertTrue(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = tomorrow))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday))
assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow))
}
}
@@ -6,6 +6,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest { class DriverUiRulesTest {
@@ -34,6 +35,23 @@ class DriverUiRulesTest {
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today)) assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
} }
@Test
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = null))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-13"))
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "", hintDate = "2026-03-12"))
}
@Test
fun formatsRouteDateChipHumanFirstWithShortFallback() {
assertEquals("dzisiaj · 30.06", routeDateChipLabel("2026-06-30", today))
assertEquals("jutro · 01.07", routeDateChipLabel("2026-07-01", today))
assertEquals("w poniedziałek · 06.07", routeDateChipLabel("2026-07-06", today))
assertEquals("02.03", routeDateChipLabel("2027-03-02", today))
assertEquals("", routeDateChipLabel("", today))
}
@Test @Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0)) assertEquals(0, inlinePhotoGridRows(0))
@@ -173,6 +191,21 @@ class DriverUiRulesTest {
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false)) assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
} }
@Test
fun showsDispatchSheetCardOnlyWhenReminderIsDueToday() {
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "missing", dueToday = true)))
assertTrue(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "uploaded", dueToday = true)))
assertFalse(shouldShowDispatchSheetReminderCard(dispatchReminder(status = "not_required", dueToday = false)))
assertFalse(shouldShowDispatchSheetReminderCard(null))
}
@Test
fun labelsDispatchSheetActionByStatusAndLocalUploadQueue() {
assertEquals("Zrób zdjęcie", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = false))
assertEquals("Wysyłam zdjęcie...", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = true))
assertEquals("Popraw", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "uploaded"), hasQueuedUpload = false))
}
private fun route(status: String): DriverRouteDto = private fun route(status: String): DriverRouteDto =
DriverRouteDto( DriverRouteDto(
id = "1", id = "1",
@@ -205,4 +238,16 @@ class DriverUiRulesTest {
status = status, status = status,
serverPhotoId = serverPhotoId, serverPhotoId = serverPhotoId,
) )
private fun dispatchReminder(status: String, dueToday: Boolean = true): DispatchSheetReminderDto =
DispatchSheetReminderDto(
enabled = dueToday,
dueToday = dueToday,
workDate = "2026-06-30",
reason = if (dueToday) "friday" else null,
availableUntil = "2026-06-30T23:59:59+02:00",
status = status,
photo = null,
canUpload = dueToday,
)
} }