Compare commits

...
11 Commits
66 changed files with 1850 additions and 423 deletions
+2
View File
@@ -5,3 +5,5 @@ app/google-services.json
app/google-services.json:Zone.Identifier
*/build/
*.iml
keystore.properties
*.jks
+35 -2
View File
@@ -1,3 +1,6 @@
import java.io.File
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -9,6 +12,13 @@ if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use(::load)
}
}
android {
namespace = "pl.firmatpp.kierowca"
compileSdk = 35
@@ -23,8 +33,9 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
versionCode = 18
versionName = "1.0.17"
versionCode = 34
versionName = "1.0.33"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"")
@@ -32,12 +43,34 @@ android {
buildConfigField("String", "REVERB_WS_BASE_URL", "\"${configValue("REVERB_WS_BASE_URL", "wss://api-intranet.firmatpp.pl/app/")}\"")
}
signingConfigs {
create("release") {
fun requiredSigningProperty(name: String): String =
keystoreProperties.getProperty(name)
?: throw GradleException("Missing '$name' in keystore.properties")
val configuredStoreFile = requiredSigningProperty("storeFile")
storeFile = if (File(configuredStoreFile).isAbsolute) {
file(configuredStoreFile)
} else {
rootProject.file(configuredStoreFile)
}
storePassword = requiredSigningProperty("storePassword")
keyAlias = requiredSigningProperty("keyAlias")
keyPassword = requiredSigningProperty("keyPassword")
}
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
@@ -1,6 +1,9 @@
package pl.firmatpp.kierowca
import android.os.Bundle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -9,13 +12,26 @@ import pl.firmatpp.kierowca.ui.DriverViewModel
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
setContent {
TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel()
DriverApp(viewModel = viewModel)
DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId)
}
}
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
}
companion object {
const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
}
}
@@ -20,6 +20,7 @@ data class ApiError(
val retryable: Boolean,
val code: String? = null,
val statusCode: Int? = null,
val responseBody: String? = null,
)
object ApiErrorMapper {
@@ -48,10 +49,17 @@ object ApiErrorMapper {
code = code,
message = message,
retryable = retryable,
responseBody = body,
)
}
fun mapProblem(statusCode: Int, code: String?, message: String?, retryable: Boolean?): ApiError {
fun mapProblem(
statusCode: Int,
code: String?,
message: String?,
retryable: Boolean?,
responseBody: String? = null,
): ApiError {
val kind = when (statusCode) {
401 -> ApiErrorKind.Auth
403 -> ApiErrorKind.Forbidden
@@ -80,6 +88,7 @@ object ApiErrorMapper {
retryable = resolvedRetryable,
code = code,
statusCode = statusCode,
responseBody = responseBody,
)
}
@@ -15,9 +15,12 @@ import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.DriverDto
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse
@@ -46,6 +49,12 @@ class DriverRepository(
suspend fun route(routeId: String): RouteResponse =
api.route(authHeader(requireToken()), routeId)
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
suspend fun updateNotificationPreferences(notifyNewRoutes: Boolean): NotificationPreferencesDto =
api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes))
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
api.syncState(authHeader(requireToken()), date, routeId)
@@ -56,6 +65,10 @@ class DriverRepository(
api.storePushToken(authHeader(requireToken()), PushTokenBody(token))
}
suspend fun storeRealtimeStatus(status: String, socketId: String? = null, error: String? = null) {
api.storeRealtimeStatus(authHeader(requireToken()), RealtimeStatusBody(status, socketId, error))
}
suspend fun deletePushToken() {
val token = tokenStore.read() ?: return
runCatching { api.deletePushToken(authHeader(token)) }
@@ -5,9 +5,13 @@ import okhttp3.RequestBody
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
import pl.firmatpp.kierowca.data.model.RequestOtpBody
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncStateResponse
@@ -34,6 +38,17 @@ interface MobileDriverApi {
@POST("mobile/driver/auth/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
@GET("mobile/driver/notification-preferences")
suspend fun notificationPreferences(
@Header("Authorization") authorization: String,
): NotificationPreferencesDto
@PUT("mobile/driver/notification-preferences")
suspend fun updateNotificationPreferences(
@Header("Authorization") authorization: String,
@Body body: NotificationPreferencesBody,
): NotificationPreferencesDto
@GET("mobile/driver/bootstrap")
suspend fun bootstrap(
@Header("Authorization") authorization: String,
@@ -46,6 +61,12 @@ interface MobileDriverApi {
@Path("routeId") routeId: String,
): RouteResponse
@POST("mobile/driver/routes/{routeId}/complete")
suspend fun completeRoute(
@Header("Authorization") authorization: String,
@Path("routeId") routeId: String,
): CompleteRouteResponse
@GET("mobile/driver/sync-state")
suspend fun syncState(
@Header("Authorization") authorization: String,
@@ -65,6 +86,12 @@ interface MobileDriverApi {
@Body body: PushTokenBody,
): Map<String, Boolean>
@PUT("mobile/driver/realtime-status")
suspend fun storeRealtimeStatus(
@Header("Authorization") authorization: String,
@Body body: RealtimeStatusBody,
): Map<String, Boolean>
@DELETE("mobile/driver/push-token")
suspend fun deletePushToken(
@Header("Authorization") authorization: String,
@@ -46,6 +46,8 @@ data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
val notificationPreferences: NotificationPreferencesDto? = null,
val realtime: RealtimeConfigDto? = null,
val syncState: SyncStateResponse? = null,
)
@@ -57,6 +59,8 @@ data class DriverAppSettingsDto(
val maxPastDays: Int,
val autoRefreshSeconds: Int,
val allowGalleryUploads: Boolean?,
val allowRouteCompletion: Boolean?,
val requirePreciseLocationForPhotos: Boolean?,
)
data class DriverSessionDto(
@@ -64,6 +68,16 @@ data class DriverSessionDto(
val lastSyncAt: String,
)
data class NotificationPreferencesDto(
val notifyNewRoutes: Boolean = false,
)
data class RealtimeConfigDto(
val reverbEnabled: Boolean = false,
val reverbAppKey: String? = null,
val reverbWsBaseUrl: String? = null,
)
data class RoutesBucketDto(
val today: List<DriverRouteDto> = emptyList(),
)
@@ -73,6 +87,14 @@ data class RouteResponse(
val syncState: SyncStateResponse? = null,
)
data class CompleteRouteResponse(
val ok: Boolean,
val alreadyCompleted: Boolean,
val message: String?,
val completedAt: String?,
val route: DriverRouteDto,
)
data class SyncStateResponse(
val scopes: List<SyncScopeDto> = emptyList(),
)
@@ -100,8 +122,19 @@ data class PushTokenBody(
val platform: String = "android",
)
data class NotificationPreferencesBody(
val notifyNewRoutes: Boolean,
)
data class RealtimeStatusBody(
val reverbStatus: String,
val socketId: String? = null,
val error: String? = null,
)
data class DriverRouteDto(
val id: String,
val routeDate: String? = null,
val startsAt: String,
val originName: String,
val destinationName: String,
@@ -113,6 +146,8 @@ data class DriverRouteDto(
val distanceKm: Double?,
val notes: String?,
val truck: TruckDto?,
val completedAt: String? = null,
val completedByDriver: DriverDto? = null,
val originNavigation: NavigationPointDto? = null,
val destinationNavigation: NavigationPointDto? = null,
val photos: List<RoutePhotoDto> = emptyList(),
@@ -5,6 +5,7 @@ import com.google.gson.Gson
import java.io.IOException
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RouteResponse
import pl.firmatpp.kierowca.data.model.SyncScopeDto
import pl.firmatpp.kierowca.data.model.SyncStateResponse
@@ -59,6 +60,32 @@ class DriverSyncRepository(
cachedRoute(routeId, throwable)
}
suspend fun cacheConfirmedRoute(route: DriverRouteDto, date: String?) {
val syncedAt = System.currentTimeMillis()
dao.upsertRoute(
DriverRouteCacheEntity(
routeId = route.id,
payloadJson = gson.toJson(RouteResponse(route = route)),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
val selectedDate = date ?: return
val cachedBootstrap = dao.bootstrap(selectedDate) ?: return
val response = gson.fromJson(cachedBootstrap.payloadJson, BootstrapResponse::class.java)
val updatedRoutes = response.routes.today.map { item -> if (item.id == route.id) route else item }
dao.upsertBootstrap(
cachedBootstrap.copy(
payloadJson = gson.toJson(response.copy(routes = response.routes.copy(today = updatedRoutes))),
checksum = null,
version = null,
syncedAtEpochMillis = syncedAt,
),
)
}
suspend fun fetchSyncState(date: String?, routeId: String?): SyncStateResponse =
repository.syncState(date, routeId)
@@ -39,10 +39,9 @@ interface PhotoUploadDao {
SELECT * FROM photo_uploads
WHERE routeId = :routeId
AND status = 'CONFIRMED'
AND clientRequestId IN (:clientRequestIds)
""",
)
suspend fun confirmedForServerPhotos(routeId: String, clientRequestIds: List<String>): List<PhotoUploadEntity>
suspend fun confirmedForRoute(routeId: String): List<PhotoUploadEntity>
@Query(
"""
@@ -0,0 +1,39 @@
package pl.firmatpp.kierowca.data.upload
import pl.firmatpp.kierowca.data.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
object PhotoUploadFailureDetails {
fun fromApiError(error: ApiError): String {
val type = when (error.kind) {
ApiErrorKind.Network -> "błąd komunikacji"
ApiErrorKind.Server,
ApiErrorKind.RateLimited,
ApiErrorKind.Validation,
ApiErrorKind.Forbidden,
ApiErrorKind.Auth,
ApiErrorKind.Conflict,
-> "błąd serwera"
ApiErrorKind.Unknown -> "błąd aplikacji"
}
return buildString {
appendLine("Typ: $type")
appendLine("Komunikat: ${error.message}")
error.statusCode?.let { appendLine("HTTP: $it") }
error.code?.takeIf { it.isNotBlank() }?.let { appendLine("Kod: $it") }
error.responseBody
?.takeIf { it.isNotBlank() }
?.let {
appendLine("Odpowiedź serwera:")
append(it.take(MAX_RESPONSE_CHARS))
if (it.length > MAX_RESPONSE_CHARS) append("...")
}
}.trim()
}
fun local(message: String): String =
"Typ: błąd aplikacji\nKomunikat: $message"
private const val MAX_RESPONSE_CHARS = 1200
}
@@ -80,10 +80,7 @@ class PhotoUploadOutbox(
}
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct()
if (confirmedRequestIds.isEmpty()) return
dao.confirmedForServerPhotos(route.id, confirmedRequestIds).forEach { upload ->
dao.confirmedForRoute(route.id).forEach { upload ->
File(upload.localPath).delete()
dao.delete(upload.clientRequestId)
}
@@ -22,11 +22,12 @@ class PhotoUploadWorker(
val file = File(upload.localPath)
if (!file.exists()) {
val message = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane."
dao.updateStatus(
clientRequestId = clientRequestId,
status = PhotoUploadStatus.FailedPermanent.storageValue,
progress = 0,
lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.",
lastError = PhotoUploadFailureDetails.local(message),
attemptIncrement = 0,
)
return Result.failure()
@@ -68,7 +69,7 @@ class PhotoUploadWorker(
clientRequestId = clientRequestId,
status = status.storageValue,
progress = 0,
lastError = error.message,
lastError = PhotoUploadFailureDetails.fromApiError(error),
attemptIncrement = 0,
)
@@ -22,6 +22,15 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
if (data["type"] == "driver_new_route_notification") {
NewRouteNotificationWorker.enqueue(
context = applicationContext,
routeId = data["routeId"],
date = data["date"],
)
return
}
if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue(
@@ -4,6 +4,7 @@ import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Job
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -14,8 +15,8 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import pl.firmatpp.kierowca.BuildConfig
import pl.firmatpp.kierowca.data.DriverRepository
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
class DriverLiveSyncClient(
private val repository: DriverRepository,
@@ -28,14 +29,21 @@ class DriverLiveSyncClient(
private val started = AtomicBoolean(false)
private var webSocket: WebSocket? = null
private var driverId: String? = null
private var realtimeConfig: RealtimeConfigDto? = null
private var socketId: String? = null
private var heartbeatJob: Job? = null
fun start(driverId: String) {
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
fun start(driverId: String, config: RealtimeConfigDto?) {
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
if (!started.compareAndSet(false, true)) return
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') +
"/" + BuildConfig.REVERB_APP_KEY +
val wsUrl = wsBaseUrl.trimEnd('/') +
"/" + appKey +
"?protocol=7&client=android&version=1.0&flash=false"
webSocket = client.newWebSocket(
@@ -46,11 +54,15 @@ class DriverLiveSyncClient(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
stopHeartbeat()
reportRealtimeStatus("disconnected", reason.takeIf { it.isNotBlank() })
started.set(false)
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
stopHeartbeat()
reportRealtimeStatus("error", t.message ?: response?.message)
started.set(false)
scheduleReconnect()
}
@@ -60,9 +72,13 @@ class DriverLiveSyncClient(
fun stop() {
started.set(false)
stopHeartbeat()
reportRealtimeStatus("disconnected", "client_stop")
webSocket?.close(1000, "logout")
webSocket = null
driverId = null
realtimeConfig = null
socketId = null
}
fun close() {
@@ -76,10 +92,18 @@ class DriverLiveSyncClient(
when (event) {
"pusher:connection_established" -> {
val socketId = root.dataObject()?.string("socket_id") ?: return
subscribe(socket, socketId)
socketId = root.dataObject()?.string("socket_id") ?: return
subscribe(socket, socketId ?: return)
}
"pusher_internal:subscription_succeeded" -> {
val channel = root.string("channel")
val id = driverId ?: return
if (channel == "private-driver-mobile.$id") {
reportRealtimeStatus("connected")
startHeartbeat()
onConnected()
}
}
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
}
}
@@ -128,7 +152,30 @@ class DriverLiveSyncClient(
scope.launch {
delay(5_000)
if (!started.get() && driverId == id) {
start(id)
start(id, realtimeConfig)
}
}
}
private fun startHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = scope.launch {
while (started.get()) {
reportRealtimeStatus("connected")
delay(30_000)
}
}
}
private fun stopHeartbeat() {
heartbeatJob?.cancel()
heartbeatJob = null
}
private fun reportRealtimeStatus(status: String, error: String? = null) {
scope.launch {
runCatching {
repository.storeRealtimeStatus(status, socketId, error)
}
}
}
@@ -0,0 +1,61 @@
package pl.firmatpp.kierowca.sync
import java.time.LocalDate
import java.util.Locale
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
import pl.firmatpp.kierowca.data.model.DriverRouteDto
object NewRouteNotificationFormatter {
private val polishLocale = Locale("pl", "PL")
private val monthFormatter = DateTimeFormatter.ofPattern("d MMMM", polishLocale)
fun title(routeDate: String?, today: LocalDate = LocalDate.now()): String {
val label = humanDate(routeDate, today) ?: return "Nowy kurs"
return "Nowy kurs: $label"
}
fun collapsedText(route: DriverRouteDto): String =
listOf(route.originName, route.destinationName)
.filter(String::isNotBlank)
.joinToString("")
.ifBlank { route.relationLabel }
.let { relation ->
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode
if (contract.isNullOrBlank()) relation else "$relation · $contract"
}
fun expandedText(route: DriverRouteDto): String {
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode ?: "Brak nazwy kontraktu"
return listOf(
"Skąd: ${route.originName.ifBlank { "Nie podano" }}",
"Dokąd: ${route.destinationName.ifBlank { "Nie podano" }}",
"Kontrakt: $contract",
).joinToString("\n")
}
private fun humanDate(routeDate: String?, today: LocalDate): String? {
val date = runCatching { routeDate?.let(LocalDate::parse) }.getOrNull() ?: return null
return when {
date == today -> "dzisiaj"
date == today.plusDays(1) -> "jutro"
date.isAfter(today.plusDays(1)) && !date.isAfter(today.plusDays(7)) ->
"w ${weekdayAccusative(date.dayOfWeek)}"
else -> date.format(monthFormatter).lowercase(polishLocale)
}
}
private fun weekdayAccusative(dayOfWeek: DayOfWeek): String =
when (dayOfWeek) {
DayOfWeek.MONDAY -> "poniedziałek"
DayOfWeek.TUESDAY -> "wtorek"
DayOfWeek.WEDNESDAY -> "środę"
DayOfWeek.THURSDAY -> "czwartek"
DayOfWeek.FRIDAY -> "piątek"
DayOfWeek.SATURDAY -> "sobotę"
DayOfWeek.SUNDAY -> "niedzielę"
}
}
@@ -0,0 +1,125 @@
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.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import java.util.concurrent.TimeUnit
import pl.firmatpp.kierowca.MainActivity
import pl.firmatpp.kierowca.R
import pl.firmatpp.kierowca.data.ApiErrorKind
import pl.firmatpp.kierowca.data.ApiErrorMapper
import pl.firmatpp.kierowca.data.DriverRepository
import retrofit2.HttpException
class NewRouteNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result {
val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
return runCatching {
val route = repository.route(routeId).route
if (!canShowNotifications(applicationContext)) {
return@runCatching Result.success()
}
createChannel(applicationContext)
val contentTitle = NewRouteNotificationFormatter.title(route.routeDate ?: inputData.getString(KEY_DATE))
val contentText = NewRouteNotificationFormatter.collapsedText(route)
val expandedText = NewRouteNotificationFormatter.expandedText(route)
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_ROUTE_ID, route.id)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
route.id.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(contentTitle)
.setContentText(contentText)
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedText))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
Result.success()
}.getOrElse { throwable ->
if (throwable is HttpException && throwable.code() == 404) {
Result.success()
} else {
val error = ApiErrorMapper.map(throwable)
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
}
}
}
private fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
"Nowe kursy",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Powiadomienia o nowych kursach dodanych przez spedytora."
}
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 = "new_routes"
private const val KEY_ROUTE_ID = "routeId"
private const val KEY_DATE = "date"
fun enqueue(context: Context, routeId: String?, date: String?) {
if (routeId.isNullOrBlank()) return
val request = OneTimeWorkRequestBuilder<NewRouteNotificationWorker>()
.setInputData(workDataOf(KEY_ROUTE_ID to routeId, KEY_DATE to date))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"driver-new-route-notification-$routeId",
ExistingWorkPolicy.REPLACE,
request,
)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,64 @@
package pl.firmatpp.kierowca.ui
import java.io.File
import java.time.LocalDate
import java.time.OffsetDateTime
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
fun canCompleteRouteFromDriverApp(
route: DriverRouteDto,
selectedDate: String,
today: LocalDate = LocalDate.now(),
): Boolean =
canManageRoutePhotos(selectedDate, today)
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2
fun authBrandBannerHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 118
screenHeightDp < 740 -> 150
else -> 190
}
fun appBrandHeaderHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 92
screenHeightDp < 740 -> 108
else -> 124
}
fun bottomNavigationHeightDp(screenHeightDp: Int): Int =
when {
screenHeightDp < 640 -> 72
screenHeightDp < 740 -> 78
else -> 82
}
fun screenHorizontalPaddingDp(screenWidthDp: Int): Int =
when {
screenWidthDp < 360 -> 14
screenWidthDp < 420 -> 16
else -> 20
}
fun phonePrefixWidthDp(screenWidthDp: Int): Int =
when {
screenWidthDp < 360 -> 70
screenWidthDp < 420 -> 82
else -> 94
}
fun routeFactColumns(screenWidthDp: Int): Int =
if (screenWidthDp < 360) 1 else 2
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
@@ -23,6 +73,37 @@ fun routePhotoSortEpochMillis(createdAt: String?, takenAt: String?, fallback: Lo
fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String? =
serverPhotoId?.takeIf { status == "CONFIRMED" && it.isNotBlank() }
fun canRetryPhotoUpload(status: String): Boolean =
status == "FAILED_RETRYABLE"
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
if (upload.localPath.isBlank()) return null
return RoutePhotoDto(
id = upload.clientRequestId,
routeId = upload.routeId,
clientRequestId = upload.clientRequestId,
contentSha256 = upload.contentSha256,
source = upload.source,
mimeType = upload.mimeType,
size = upload.size,
url = File(upload.localPath).toURI().toString(),
takenAt = upload.takenAt,
latitude = upload.latitude,
longitude = upload.longitude,
locationAccuracyMeters = upload.locationAccuracyMeters,
createdAt = null,
canDelete = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null,
deleteUntil = null,
)
}
fun canLaunchCameraWithLocationPolicy(requirePreciseLocation: Boolean, hasFineLocation: Boolean): Boolean =
!requirePreciseLocation || hasFineLocation
private fun parseIsoOffsetEpochMillis(value: String?): Long? =
value?.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
@@ -42,15 +42,21 @@ data class DriverUiState(
val maxRouteDate: String = LocalDate.now().plusDays(1).toString(),
val autoRefreshSeconds: Int = 60,
val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
val notifyNewRoutes: Boolean = false,
val notificationPermissionDenied: Boolean = false,
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List<PhotoUploadEntity> = emptyList(),
val queuedPhotoUploads: List<PhotoUploadEntity> = emptyList(),
val deletingPhotoIds: Set<String> = emptySet(),
val completingRoute: Boolean = false,
val imageAuthHeader: String? = null,
val isOnline: Boolean = true,
val isStale: Boolean = false,
val lastSuccessfulSyncAtEpochMillis: Long? = null,
val feedback: String? = null,
val error: String? = null,
)
@@ -135,7 +141,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
viewModelScope.launch {
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null) }
_state.update { it.copy(loading = showLoading, refreshing = !showLoading, feedback = null, error = null) }
runCatching {
val cached = syncRepository.bootstrap(date)
@@ -151,6 +157,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
maxRouteDate = settings?.maxDate ?: it.maxRouteDate,
autoRefreshSeconds = settings?.autoRefreshSeconds ?: it.autoRefreshSeconds,
allowGalleryUploads = settings?.allowGalleryUploads ?: it.allowGalleryUploads,
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
@@ -158,7 +168,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
)
}
response.session.driver.id.let { driverId ->
liveSyncClient.start(driverId)
liveSyncClient.start(driverId, response.realtime)
registerPushTokenIfAvailable(driverId)
}
}.onFailure { throwable ->
@@ -190,6 +200,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
it.copy(
screen = DriverScreen.Detail,
selectedRoute = response.route,
feedback = null,
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
error = null,
@@ -202,7 +213,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
val routeId = _state.value.selectedRoute?.id ?: return
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null) }
_state.update { it.copy(refreshing = true, feedback = null, error = null) }
runCatching { syncRepository.route(routeId) }
.onSuccess { cached ->
@@ -230,6 +241,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
}
fun updateNewRouteNotifications(enabled: Boolean) {
viewModelScope.launch {
_state.update { it.copy(refreshing = true, error = null, notificationPermissionDenied = false) }
runCatching { repository.updateNotificationPreferences(enabled) }
.onSuccess { preferences ->
_state.update {
it.copy(
notifyNewRoutes = preferences.notifyNewRoutes,
feedback = if (preferences.notifyNewRoutes) "Powiadomienia o nowych kursach są włączone." else "Powiadomienia o nowych kursach są wyłączone.",
error = null,
)
}
}
.onFailure { throwable ->
_state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(refreshing = false) }
}
}
fun markNotificationPermissionDenied() {
_state.update {
it.copy(
notifyNewRoutes = false,
notificationPermissionDenied = true,
error = "Aby otrzymywać powiadomienia o nowych kursach, włącz powiadomienia w ustawieniach aplikacji.",
)
}
}
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
@@ -301,10 +342,46 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
}
}
fun completeSelectedRoute() {
val route = _state.value.selectedRoute ?: return
if (_state.value.completingRoute || !_state.value.isOnline) return
viewModelScope.launch {
_state.update { it.copy(completingRoute = true, feedback = null, error = null) }
runCatching {
val completed = repository.completeRoute(route.id).route
syncRepository.cacheConfirmedRoute(completed, _state.value.selectedDate)
val refreshed = runCatching { syncRepository.route(route.id) }
.getOrNull()
?.takeUnless { it.stale }
?.value
?.route
?: completed
photoUploadOutbox.discardConfirmedServerPhotos(refreshed)
_state.update {
it.copy(
selectedRoute = refreshed,
routes = it.routes.map { item -> if (item.id == route.id) refreshed else item },
feedback = "Kurs zakończony",
error = null,
)
}
}.onFailure { throwable ->
_state.update { it.copy(feedback = null, error = ApiErrorMapper.map(throwable).message) }
}
_state.update { it.copy(completingRoute = false) }
}
}
fun openPhoto(photo: RoutePhotoDto) {
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
}
fun openRouteFromNotification(routeId: String?) {
if (routeId.isNullOrBlank()) return
openRoute(routeId)
}
fun back() {
_state.update {
when (it.screen) {
@@ -312,7 +389,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Detail -> {
photoUploadsJob?.cancel()
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList())
it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null)
}
DriverScreen.Profile -> it.copy(screen = DriverScreen.Routes)
DriverScreen.Otp -> it.copy(screen = DriverScreen.Phone)
@@ -0,0 +1,38 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
object PolishPhoneNumberVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val rawDigits = text.text.filter(Char::isDigit).take(9)
val formatted = rawDigits.chunked(3).joinToString(" ")
return TransformedText(
AnnotatedString(formatted),
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val safeOffset = offset.coerceIn(0, rawDigits.length)
val spacesBeforeOffset = when {
safeOffset <= 3 -> 0
safeOffset <= 6 -> 1
else -> 2
}
return (safeOffset + spacesBeforeOffset).coerceAtMost(formatted.length)
}
override fun transformedToOriginal(offset: Int): Int {
val safeOffset = offset.coerceIn(0, formatted.length)
return formatted
.take(safeOffset)
.count(Char::isDigit)
.coerceAtMost(rawDigits.length)
}
},
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -1,4 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#F7FAF8" />
</shape>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FFFFFF"
android:pathData="M0,0h108v108h-108z"/>
</vector>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0B192E</color>
</resources>
@@ -39,4 +39,16 @@ class ApiErrorMapperTest {
assertTrue(error.retryable)
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
}
@Test
fun keepsRawServerResponseForTroubleshooting() {
val body = """{"code":"PHOTO_SERVER_UNAVAILABLE","message":"Storage timeout","retryable":true}"""
val error = ApiErrorMapper.mapHttpStatus(503, body)
assertEquals(ApiErrorKind.Server, error.kind)
assertEquals(503, error.statusCode)
assertEquals("PHOTO_SERVER_UNAVAILABLE", error.code)
assertEquals("Storage timeout", error.message)
assertEquals(body, error.responseBody)
}
}
@@ -0,0 +1,44 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.ApiError
import pl.firmatpp.kierowca.data.ApiErrorKind
class PhotoUploadFailureDetailsTest {
@Test
fun describesServerFailureWithStatusCodeBackendCodeAndResponseBody() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Server,
message = "Storage timeout",
retryable = true,
code = "PHOTO_STORAGE_FAILED",
statusCode = 503,
responseBody = """{"code":"PHOTO_STORAGE_FAILED","message":"Storage timeout"}""",
),
)
assertTrue(details.contains("Typ: błąd serwera"))
assertTrue(details.contains("HTTP: 503"))
assertTrue(details.contains("Kod: PHOTO_STORAGE_FAILED"))
assertTrue(details.contains("Odpowiedź serwera:"))
assertTrue(details.contains("Storage timeout"))
}
@Test
fun describesNetworkFailureWithoutHttpStatus() {
val details = PhotoUploadFailureDetails.fromApiError(
ApiError(
kind = ApiErrorKind.Network,
message = "Nie udało się połączyć z serwerem.",
retryable = true,
),
)
assertTrue(details.contains("Typ: błąd komunikacji"))
assertTrue(details.contains("Nie udało się połączyć z serwerem."))
assertFalse(details.contains("HTTP:"))
}
}
@@ -0,0 +1,46 @@
package pl.firmatpp.kierowca.sync
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
class NewRouteNotificationFormatterTest {
@Test
fun formatsHumanRouteDates() {
val today = LocalDate.of(2026, 7, 2)
assertEquals("Nowy kurs: dzisiaj", NewRouteNotificationFormatter.title("2026-07-02", today))
assertEquals("Nowy kurs: jutro", NewRouteNotificationFormatter.title("2026-07-03", today))
assertEquals("Nowy kurs: w sobotę", NewRouteNotificationFormatter.title("2026-07-04", today))
assertEquals("Nowy kurs: 12 lipca", NewRouteNotificationFormatter.title("2026-07-12", today))
}
@Test
fun formatsCollapsedAndExpandedRouteText() {
val route = DriverRouteDto(
id = "42",
routeDate = "2026-07-03",
startsAt = "09:15",
originName = "TPP Baza Krakow",
destinationName = "Instalacja Odpadowa Slask",
contractorName = "TPP Baza Krakow",
contractName = "Odpady przemyslowe",
contractCode = "TPP-ODP-24",
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
status = "ZAPLANOWANA",
distanceKm = 91.4,
notes = null,
truck = null,
)
assertEquals(
"TPP Baza Krakow → Instalacja Odpadowa Slask · Odpady przemyslowe",
NewRouteNotificationFormatter.collapsedText(route),
)
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Skąd: TPP Baza Krakow"))
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Dokąd: Instalacja Odpadowa Slask"))
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Kontrakt: Odpady przemyslowe"))
}
}
@@ -5,6 +5,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
class DriverUiRulesTest {
private val today = LocalDate.parse("2026-06-30")
@@ -22,6 +24,16 @@ class DriverUiRulesTest {
assertFalse(canManageRoutePhotos("not-a-date", today))
}
@Test
fun allowsRouteCompletionOnlyForTodayPlannedOrInProgressRoutes() {
assertTrue(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-30", today))
assertTrue(canCompleteRouteFromDriverApp(route(status = "W TRAKCIE"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAKOŃCZONA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "NIEROZPOZNANA"), "2026-06-30", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-07-01", today))
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
}
@Test
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
assertEquals(0, inlinePhotoGridRows(0))
@@ -31,6 +43,38 @@ class DriverUiRulesTest {
assertEquals(3, inlinePhotoGridRows(5))
}
@Test
fun usesShorterChromeOnCompactScreens() {
assertEquals(118, authBrandBannerHeightDp(600))
assertEquals(150, authBrandBannerHeightDp(690))
assertEquals(190, authBrandBannerHeightDp(780))
assertEquals(92, appBrandHeaderHeightDp(600))
assertEquals(108, appBrandHeaderHeightDp(690))
assertEquals(124, appBrandHeaderHeightDp(780))
assertEquals(72, bottomNavigationHeightDp(600))
assertEquals(78, bottomNavigationHeightDp(690))
assertEquals(82, bottomNavigationHeightDp(780))
}
@Test
fun reducesHorizontalSpaceOnNarrowScreens() {
assertEquals(14, screenHorizontalPaddingDp(340))
assertEquals(16, screenHorizontalPaddingDp(390))
assertEquals(20, screenHorizontalPaddingDp(440))
assertEquals(70, phonePrefixWidthDp(340))
assertEquals(82, phonePrefixWidthDp(390))
assertEquals(94, phonePrefixWidthDp(440))
}
@Test
fun stacksDenseRouteFactsOnNarrowScreens() {
assertEquals(1, routeFactColumns(340))
assertEquals(2, routeFactColumns(390))
}
@Test
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))
@@ -87,4 +131,78 @@ class DriverUiRulesTest {
assertEquals(null, confirmedUploadServerPhotoId("UPLOADING", "42"))
assertEquals(null, confirmedUploadServerPhotoId("FAILED_RETRYABLE", "42"))
}
@Test
fun allowsRetryActionOnlyForRetryableFailedUploads() {
assertTrue(canRetryPhotoUpload("FAILED_RETRYABLE"))
assertFalse(canRetryPhotoUpload("PENDING"))
assertFalse(canRetryPhotoUpload("UPLOADING"))
assertFalse(canRetryPhotoUpload("VERIFYING"))
assertFalse(canRetryPhotoUpload("CONFIRMED"))
assertFalse(canRetryPhotoUpload("FAILED_PERMANENT"))
assertFalse(canRetryPhotoUpload("CANCELLED"))
}
@Test
fun showsFailureDetailsOnlyForFailedUploadsWithErrorMessage() {
assertTrue(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", "Typ: błąd serwera"))
assertTrue(canShowPhotoUploadFailureDetails("FAILED_PERMANENT", "Typ: błąd aplikacji"))
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", null))
assertFalse(canShowPhotoUploadFailureDetails("FAILED_RETRYABLE", ""))
assertFalse(canShowPhotoUploadFailureDetails("UPLOADING", "Typ: błąd serwera"))
assertFalse(canShowPhotoUploadFailureDetails("CONFIRMED", "Typ: błąd serwera"))
}
@Test
fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() {
val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))
requireNotNull(photo)
assertEquals("local-upload-123", photo.id)
assertEquals("10", photo.routeId)
assertEquals("file:/tmp/photo.jpg", photo.url)
assertEquals("2026-07-01T08:00:00Z", photo.takenAt)
assertEquals(50.1, photo.latitude ?: 0.0, 0.0)
assertEquals(true, photo.canDelete)
}
@Test
fun preciseLocationRequirementAllowsCameraOnlyWithFineLocation() {
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = false, hasFineLocation = false))
assertTrue(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = true))
assertFalse(canLaunchCameraWithLocationPolicy(requirePreciseLocation = true, hasFineLocation = false))
}
private fun route(status: String): DriverRouteDto =
DriverRouteDto(
id = "1",
startsAt = "",
originName = "Baza",
destinationName = "Instalacja",
contractorName = "TPP",
contractName = "Kontrakt",
contractCode = "TPP-1",
relationLabel = "Baza -> Instalacja",
status = status,
distanceKm = 1.0,
notes = null,
truck = null,
)
private fun upload(status: String, serverPhotoId: String? = null): PhotoUploadEntity =
PhotoUploadEntity(
clientRequestId = "local-upload-123",
routeId = "10",
localPath = "/tmp/photo.jpg",
source = "camera",
takenAt = "2026-07-01T08:00:00Z",
latitude = 50.1,
longitude = 19.9,
locationAccuracyMeters = 8.0,
mimeType = "image/jpeg",
size = 1200,
contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
status = status,
serverPhotoId = serverPhotoId,
)
}
@@ -0,0 +1,27 @@
package pl.firmatpp.kierowca.ui
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Test
class PolishPhoneNumberVisualTransformationTest {
@Test
fun formatsPhoneDigitsWithoutChangingUnderlyingTextOffsets() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("1234"))
assertEquals("123 4", transformed.text.text)
assertEquals(5, transformed.offsetMapping.originalToTransformed(4))
assertEquals(4, transformed.offsetMapping.transformedToOriginal(5))
}
@Test
fun mapsOffsetsAcrossBothInsertedSpaces() {
val transformed = PolishPhoneNumberVisualTransformation.filter(AnnotatedString("123456789"))
assertEquals("123 456 789", transformed.text.text)
assertEquals(7, transformed.offsetMapping.originalToTransformed(6))
assertEquals(11, transformed.offsetMapping.originalToTransformed(9))
assertEquals(6, transformed.offsetMapping.transformedToOriginal(7))
assertEquals(9, transformed.offsetMapping.transformedToOriginal(11))
}
}