Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0115e85fc | ||
|
|
0e15d5f9a4 | ||
|
|
5555676395 | ||
|
|
ca512f5993 | ||
|
|
64d6f5a41d | ||
|
|
a3d39764e2 | ||
|
|
705b8904a4 | ||
|
|
541dda292f |
@@ -33,8 +33,8 @@ android {
|
||||
applicationId = "pl.firmatpp.kierowca"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 28
|
||||
versionName = "1.0.27"
|
||||
versionCode = 37
|
||||
versionName = "1.0.36"
|
||||
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -49,6 +52,9 @@ class DriverRepository(
|
||||
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)
|
||||
|
||||
@@ -59,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)) }
|
||||
|
||||
@@ -7,8 +7,11 @@ 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
|
||||
@@ -35,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,
|
||||
@@ -72,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,
|
||||
)
|
||||
|
||||
@@ -66,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(),
|
||||
)
|
||||
@@ -110,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -24,18 +25,29 @@ class DriverLiveSyncClient(
|
||||
private val client: OkHttpClient = OkHttpClient(),
|
||||
private val gson: Gson = Gson(),
|
||||
) {
|
||||
private companion object {
|
||||
const val HEARTBEAT_INTERVAL_MS = 10_000L
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
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 +58,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 +76,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 +96,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 +156,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(HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
@@ -56,6 +57,7 @@ import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.CloudUpload
|
||||
import androidx.compose.material.icons.outlined.Factory
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.LocalShipping
|
||||
import androidx.compose.material.icons.outlined.Navigation
|
||||
@@ -79,6 +81,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -103,12 +106,14 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -137,10 +142,20 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper
|
||||
import pl.firmatpp.kierowca.ui.theme.TppColors
|
||||
|
||||
@Composable
|
||||
fun DriverApp(viewModel: DriverViewModel) {
|
||||
fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
var appInForeground by remember { mutableStateOf(true) }
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
viewModel.updateNewRouteNotifications(true)
|
||||
} else {
|
||||
viewModel.markNotificationPermissionDenied()
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
|
||||
viewModel.back()
|
||||
@@ -172,6 +187,9 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
|
||||
onCode = viewModel::updateOtpCode,
|
||||
)
|
||||
LaunchedEffect(initialRouteId) {
|
||||
viewModel.openRouteFromNotification(initialRouteId)
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
|
||||
when (state.screen) {
|
||||
@@ -185,8 +203,26 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
onProfile = viewModel::openProfile,
|
||||
onPhotoQueue = viewModel::openPhotoQueue,
|
||||
onRoute = viewModel::openRoute,
|
||||
onDismissLiveUpdate = viewModel::dismissRouteDayLiveUpdate,
|
||||
)
|
||||
DriverScreen.Profile -> ProfileScreen(
|
||||
state = state,
|
||||
onRoutes = viewModel::refreshRoutes,
|
||||
onProfile = viewModel::openProfile,
|
||||
onLogout = viewModel::logout,
|
||||
onNewRouteNotificationsChanged = { enabled ->
|
||||
if (!enabled) {
|
||||
viewModel.updateNewRouteNotifications(false)
|
||||
} else if (canPostNotifications(context)) {
|
||||
viewModel.updateNewRouteNotifications(true)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasPostNotificationsRuntimePermission(context)) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
viewModel.markNotificationPermissionDenied()
|
||||
}
|
||||
},
|
||||
onOpenNotificationSettings = { openAppNotificationSettings(context) },
|
||||
)
|
||||
DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout)
|
||||
DriverScreen.Detail -> DetailScreen(
|
||||
state,
|
||||
viewModel::back,
|
||||
@@ -216,25 +252,29 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
|
||||
@Composable
|
||||
private fun StartupScreen() {
|
||||
Column(
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.background(TppColors.Surface),
|
||||
) {
|
||||
val bannerHeight = authBrandBannerHeightDp(maxHeight.value.toInt()).dp
|
||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 178.dp)
|
||||
BrandBannerHeader(height = bannerHeight)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
.padding(horizontal = horizontalPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
)
|
||||
{
|
||||
) {
|
||||
CircularProgressIndicator(color = TppColors.Forest)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
@@ -245,25 +285,37 @@ private fun StartupScreen() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
||||
Column(
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(TppColors.Surface)
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
val screenWidthDp = maxWidth.value.toInt()
|
||||
val screenHeightDp = maxHeight.value.toInt()
|
||||
val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
|
||||
val bannerHeight = authBrandBannerHeightDp(screenHeightDp).dp
|
||||
val formTopPadding = if (screenHeightDp < 640) 18.dp else 28.dp
|
||||
val cardPadding = if (screenWidthDp < 360) 16.dp else 20.dp
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 190.dp)
|
||||
BrandBannerHeader(height = bannerHeight)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp),
|
||||
.padding(start = horizontalPadding, top = formTopPadding, end = horizontalPadding, bottom = 28.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
@@ -287,14 +339,14 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Spacer(Modifier.height(if (screenHeightDp < 640) 18.dp else 28.dp))
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
|
||||
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(cardPadding), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Text(
|
||||
"Numer telefonu",
|
||||
color = TppColors.Ink,
|
||||
@@ -304,6 +356,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
)
|
||||
PhoneNumberField(
|
||||
digits = phoneDigits,
|
||||
screenWidthDp = screenWidthDp,
|
||||
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
|
||||
)
|
||||
Text(
|
||||
@@ -333,6 +386,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -376,23 +430,29 @@ private fun BrandBannerHeader(height: Dp) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
|
||||
private fun PhoneNumberField(digits: String, screenWidthDp: Int, onDigitsChange: (String) -> Unit) {
|
||||
val fieldHeight = if (screenWidthDp < 360) 68.dp else if (screenWidthDp < 420) 76.dp else 82.dp
|
||||
val prefixWidth = phonePrefixWidthDp(screenWidthDp).dp
|
||||
val prefixFontSize = if (screenWidthDp < 360) 20.sp else if (screenWidthDp < 420) 23.sp else 26.sp
|
||||
val inputFontSize = if (screenWidthDp < 360) 22.sp else if (screenWidthDp < 420) 25.sp else 28.sp
|
||||
val inputPadding = if (screenWidthDp < 360) 14.dp else if (screenWidthDp < 420) 18.dp else 24.dp
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(82.dp)
|
||||
.height(fieldHeight)
|
||||
.background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp))
|
||||
.border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(94.dp)
|
||||
.width(prefixWidth)
|
||||
.fillMaxHeight()
|
||||
.background(TppColors.Panel),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text("+48", color = TppColors.Ink, fontSize = 26.sp)
|
||||
Text("+48", color = TppColors.Ink, fontSize = prefixFontSize)
|
||||
}
|
||||
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
|
||||
BasicTextField(
|
||||
@@ -403,19 +463,19 @@ private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
|
||||
visualTransformation = PolishPhoneNumberVisualTransformation,
|
||||
textStyle = TextStyle(
|
||||
color = TppColors.Muted,
|
||||
fontSize = 28.sp,
|
||||
fontSize = inputFontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp),
|
||||
.padding(horizontal = inputPadding),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (digits.isBlank()) {
|
||||
Text(
|
||||
"000 000 000",
|
||||
color = TppColors.Muted.copy(alpha = 0.92f),
|
||||
fontSize = 28.sp,
|
||||
fontSize = inputFontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
@@ -450,15 +510,32 @@ private fun OtpScreen(
|
||||
|
||||
@Composable
|
||||
private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit) {
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(TppColors.Surface)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(20.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = horizontalPadding, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") }
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz")
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = TppColors.Forest)
|
||||
Text(subtitle, color = TppColors.Muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@@ -470,12 +547,37 @@ private fun RoutesScreen(
|
||||
onProfile: () -> Unit,
|
||||
onPhotoQueue: () -> Unit,
|
||||
onRoute: (String) -> Unit,
|
||||
onDismissLiveUpdate: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(state.routeDayLiveUpdateMessage) {
|
||||
if (!state.routeDayLiveUpdateMessage.isNullOrBlank()) {
|
||||
delay(6_000)
|
||||
onDismissLiveUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val screenWidthDp = maxWidth.value.toInt()
|
||||
val screenHeightDp = maxHeight.value.toInt()
|
||||
val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
|
||||
val compactWidth = screenWidthDp < 360
|
||||
|
||||
Scaffold(
|
||||
topBar = { StitchHeader() },
|
||||
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 = {
|
||||
StitchBottomBar(
|
||||
activeScreen = DriverScreen.Routes,
|
||||
height = bottomNavigationHeightDp(screenHeightDp).dp,
|
||||
onRoutes = onRefresh,
|
||||
onProfile = onProfile,
|
||||
)
|
||||
@@ -486,10 +588,21 @@ private fun RoutesScreen(
|
||||
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 20.dp),
|
||||
contentPadding = PaddingValues(start = horizontalPadding, top = 20.dp, end = horizontalPadding, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
item {
|
||||
if (compactWidth) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
routeListTitle(state.selectedDate),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TppColors.Ink,
|
||||
)
|
||||
DateChip(state.selectedDate)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -504,6 +617,7 @@ private fun RoutesScreen(
|
||||
DateChip(state.selectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
RouteDateSelector(
|
||||
selectedDate = state.selectedDate,
|
||||
@@ -530,6 +644,38 @@ private fun RoutesScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -580,13 +726,16 @@ private fun PhotoQueueScreen(
|
||||
) {
|
||||
val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis }
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||
|
||||
Scaffold(
|
||||
topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") },
|
||||
containerColor = TppColors.Surface,
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item { OfflineStaleBanner(state) }
|
||||
@@ -607,11 +756,14 @@ private fun PhotoQueueScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploadEntity) -> Unit) {
|
||||
val status = upload.statusType
|
||||
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
|
||||
var showFailureDetails by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
@@ -632,8 +784,8 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label,
|
||||
color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Muted,
|
||||
upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label,
|
||||
color = if (hasFailureDetails) TppColors.Error else TppColors.Muted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
@@ -646,6 +798,24 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasFailureDetails || canRetryPhotoUpload(upload.status)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (hasFailureDetails) {
|
||||
IconButton(
|
||||
onClick = { showFailureDetails = true },
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(TppColors.Surface, RoundedCornerShape(20.dp))
|
||||
.border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
contentDescription = "Pokaż szczegóły błędu",
|
||||
tint = TppColors.Error,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (canRetryPhotoUpload(upload.status)) {
|
||||
Button(
|
||||
onClick = { onRetryUpload(upload) },
|
||||
@@ -665,12 +835,44 @@ private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploa
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showFailureDetails) {
|
||||
PhotoUploadFailureDialog(
|
||||
details = upload.lastError.orEmpty(),
|
||||
onDismiss = { showFailureDetails = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StitchHeader() {
|
||||
private fun PhotoUploadFailureDialog(details: String, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("Dlaczego nie wysłano zdjęcia?", color = TppColors.Ink, fontWeight = FontWeight.Bold)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
details,
|
||||
color = TppColors.Ink,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.heightIn(max = 320.dp).verticalScroll(rememberScrollState()),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Zamknij", color = TppColors.Forest, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StitchHeader(height: Dp = 124.dp) {
|
||||
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 124.dp)
|
||||
BrandBannerHeader(height = height)
|
||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
@@ -758,15 +960,30 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val compactWidth = maxWidth < 340.dp
|
||||
val contentPadding = if (compactWidth) 16.dp else 24.dp
|
||||
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.width(8.dp)
|
||||
.width(6.dp)
|
||||
.background(stripColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)),
|
||||
)
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 20.dp)) {
|
||||
Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) {
|
||||
if (compactWidth) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
StatusChip(route.status, stripColor)
|
||||
Text(
|
||||
"Kontrakt: #${display.contractLabel.trimStart('#')}",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = TppColors.Ink,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -780,7 +997,8 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(26.dp))
|
||||
}
|
||||
Spacer(Modifier.height(if (compactWidth) 18.dp else 26.dp))
|
||||
RouteTimeline(display.origin, display.destination)
|
||||
}
|
||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.55f))
|
||||
@@ -795,6 +1013,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -812,7 +1031,7 @@ private fun StatusChip(status: String, color: Color) {
|
||||
|
||||
@Composable
|
||||
private fun RouteTimeline(origin: String, destination: String) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.size(18.dp).background(Color.White, RoundedCornerShape(50)).padding(3.dp)) {
|
||||
Box(Modifier.fillMaxSize().background(TppColors.Forest, RoundedCornerShape(50)))
|
||||
@@ -820,7 +1039,7 @@ private fun RouteTimeline(origin: String, destination: String) {
|
||||
Box(Modifier.width(3.dp).height(64.dp).background(TppColors.Outline.copy(alpha = 0.8f)))
|
||||
Box(Modifier.size(18.dp).background(TppColors.Navy, RoundedCornerShape(50)))
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(30.dp)) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(30.dp)) {
|
||||
Column {
|
||||
Text("SKĄD", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
||||
Text(origin, style = MaterialTheme.typography.titleLarge, color = TppColors.Ink, fontWeight = FontWeight.Bold)
|
||||
@@ -839,12 +1058,20 @@ private fun ProfileScreen(
|
||||
onRoutes: () -> Unit,
|
||||
onProfile: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNewRouteNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val screenWidthDp = maxWidth.value.toInt()
|
||||
val screenHeightDp = maxHeight.value.toInt()
|
||||
val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
|
||||
|
||||
Scaffold(
|
||||
topBar = { StitchHeader() },
|
||||
topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) },
|
||||
bottomBar = {
|
||||
StitchBottomBar(
|
||||
activeScreen = DriverScreen.Profile,
|
||||
height = bottomNavigationHeightDp(screenHeightDp).dp,
|
||||
onRoutes = onRoutes,
|
||||
onProfile = onProfile,
|
||||
)
|
||||
@@ -855,7 +1082,8 @@ private fun ProfileScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = horizontalPadding, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Card(
|
||||
@@ -874,6 +1102,55 @@ private fun ProfileScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(
|
||||
"Ustawienia",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TppColors.Ink,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
"Otrzymuj powiadomienia gdy spedytor doda nowy kurs",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TppColors.Ink,
|
||||
)
|
||||
Text(
|
||||
"Ta opcja może wprowadzać w błąd, ponieważ część kursów może zostać później usunięta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TppColors.Muted,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.notifyNewRoutes,
|
||||
onCheckedChange = onNewRouteNotificationsChanged,
|
||||
enabled = !state.refreshing && !state.loading,
|
||||
)
|
||||
}
|
||||
if (state.notificationPermissionDenied) {
|
||||
Button(
|
||||
onClick = onOpenNotificationSettings,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Navy),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Text("Przejdź do ustawień", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
@@ -884,14 +1161,35 @@ private fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPostNotifications(context: Context): Boolean =
|
||||
hasPostNotificationsRuntimePermission(context) &&
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
|
||||
private fun hasPostNotificationsRuntimePermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun openAppNotificationSettings(context: Context) {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.setData(Uri.parse("package:${context.packageName}"))
|
||||
}
|
||||
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StitchBottomBar(activeScreen: DriverScreen, onRoutes: () -> Unit, onProfile: () -> Unit) {
|
||||
private fun StitchBottomBar(activeScreen: DriverScreen, height: Dp = 82.dp, onRoutes: () -> Unit, onProfile: () -> Unit) {
|
||||
Column(Modifier.fillMaxWidth().background(Color.White).navigationBarsPadding()) {
|
||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.9f), thickness = 2.dp)
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(82.dp),
|
||||
Modifier.fillMaxWidth().height(height),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -974,6 +1272,9 @@ private fun DetailScreen(
|
||||
val route = state.selectedRoute
|
||||
val canManagePhotos = canManageRoutePhotos(state.selectedDate)
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
DetailHeader(onBack)
|
||||
@@ -984,10 +1285,10 @@ private fun DetailScreen(
|
||||
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.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 {
|
||||
RouteCompletionSection(
|
||||
@@ -1041,6 +1342,7 @@ private fun DetailScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCompleteDialog && route != null) {
|
||||
AlertDialog(
|
||||
@@ -1113,7 +1415,9 @@ private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy"
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TppColors.Ink,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f).padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = TppColors.Outline, thickness = 1.dp)
|
||||
@@ -1155,17 +1459,11 @@ private fun RouteCompletionSection(
|
||||
|
||||
if (!canCompleteRouteFromDriverApp(route, selectedDate)) return
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = onComplete,
|
||||
enabled = isOnline && !completing,
|
||||
modifier = Modifier.fillMaxWidth().height(58.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(64.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = TppColors.Forest,
|
||||
disabledContainerColor = TppColors.Outline,
|
||||
@@ -1194,10 +1492,10 @@ private fun RouteCompletionSection(
|
||||
color = TppColors.Muted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun photoCountLabel(count: Int): String =
|
||||
@@ -1208,8 +1506,9 @@ private fun photoCountLabel(count: Int): String =
|
||||
}
|
||||
|
||||
@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 routeDate = route.routeDate ?: selectedDate
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -1217,13 +1516,17 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
|
||||
) {
|
||||
Column {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
|
||||
StatusChip(route.status, stripColor)
|
||||
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||
RouteDateStatusChip(routeDate)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Zlecenie #${route.contractCode ?: route.id}",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
@@ -1232,7 +1535,6 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
|
||||
RoutePointBlock(
|
||||
label = "Załadunek",
|
||||
@@ -1261,6 +1563,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
|
||||
private fun RoutePointBlock(
|
||||
label: String,
|
||||
@@ -1275,14 +1603,14 @@ private fun RoutePointBlock(
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(48.dp).background(iconBackground, RoundedCornerShape(24.dp)),
|
||||
Modifier.size(40.dp).background(iconBackground, RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(26.dp))
|
||||
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
@@ -1307,9 +1635,9 @@ private fun RoutePointBlock(
|
||||
if (navigationPoint?.hasCoordinates() == true) {
|
||||
IconButton(
|
||||
onClick = { onNavigate(navigationPoint) },
|
||||
modifier = Modifier.size(48.dp).background(TppColors.Forest, RoundedCornerShape(24.dp)),
|
||||
modifier = Modifier.size(42.dp).background(TppColors.Forest, RoundedCornerShape(21.dp)),
|
||||
) {
|
||||
Icon(Icons.Outlined.Navigation, contentDescription = "Nawiguj", tint = Color.White)
|
||||
Icon(Icons.Outlined.Navigation, contentDescription = "Nawiguj", tint = Color.White, modifier = Modifier.size(23.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1317,7 +1645,16 @@ private fun RoutePointBlock(
|
||||
|
||||
@Composable
|
||||
private fun RouteFactsFooter(route: DriverRouteDto) {
|
||||
Column(Modifier.fillMaxWidth().background(TppColors.Panel).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().background(TppColors.Panel)) {
|
||||
val columns = routeFactColumns(maxWidth.value.toInt())
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
if (columns == 1) {
|
||||
RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.fillMaxWidth())
|
||||
RouteFact("Pojazd", route.truckLabel(), Modifier.fillMaxWidth())
|
||||
RouteFact("Nr kontraktu", route.contractCode ?: "#${route.id}", Modifier.fillMaxWidth())
|
||||
RouteFact("Dystans", route.distanceLabel(), Modifier.fillMaxWidth())
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.weight(1f))
|
||||
RouteFact("Pojazd", route.truckLabel(), Modifier.weight(1f))
|
||||
@@ -1327,6 +1664,8 @@ private fun RouteFactsFooter(route: DriverRouteDto) {
|
||||
RouteFact("Dystans", route.distanceLabel(), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1409,7 +1748,12 @@ private fun PhotoGrid(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val compactWidth = maxWidth < 340.dp
|
||||
val contentPadding = if (compactWidth) 12.dp else 20.dp
|
||||
val gridGap = if (compactWidth) 8.dp else 12.dp
|
||||
|
||||
Column(Modifier.padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"Załączone zdjęcia ($visibleAttachmentCount)",
|
||||
color = TppColors.Muted,
|
||||
@@ -1419,11 +1763,11 @@ private fun PhotoGrid(
|
||||
if (items.isEmpty()) {
|
||||
EmptyPhotoState()
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(gridGap)) {
|
||||
repeat(inlinePhotoGridRows(items.size)) { rowIndex ->
|
||||
val startIndex = rowIndex * 2
|
||||
val rowItems = items.subList(startIndex, minOf(startIndex + 2, items.size))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(gridGap), modifier = Modifier.fillMaxWidth()) {
|
||||
rowItems.forEach { item ->
|
||||
when (item) {
|
||||
is PhotoGridItem.Server -> PhotoTile(
|
||||
@@ -1453,6 +1797,7 @@ private fun PhotoGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingDelete?.let { target ->
|
||||
AlertDialog(
|
||||
@@ -1551,6 +1896,14 @@ private fun PendingPhotoTile(
|
||||
) {
|
||||
val status = upload.statusType
|
||||
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
|
||||
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
|
||||
var showFailureDetails by remember { mutableStateOf(false) }
|
||||
if (showFailureDetails) {
|
||||
PhotoUploadFailureDialog(
|
||||
details = upload.lastError.orEmpty(),
|
||||
onDismiss = { showFailureDetails = false },
|
||||
)
|
||||
}
|
||||
Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) {
|
||||
AsyncImage(
|
||||
model = File(upload.localPath),
|
||||
@@ -1586,6 +1939,25 @@ private fun PendingPhotoTile(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasFailureDetails) {
|
||||
IconButton(
|
||||
onClick = { showFailureDetails = true },
|
||||
enabled = !isDeleting,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
.size(40.dp)
|
||||
.background(TppColors.Surface.copy(alpha = 0.96f), RoundedCornerShape(20.dp))
|
||||
.border(1.dp, TppColors.Error.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Info,
|
||||
contentDescription = "Pokaż szczegóły błędu wysłania",
|
||||
tint = TppColors.Error,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (canRetryPhotoUpload(upload.status)) {
|
||||
IconButton(
|
||||
onClick = { onRetry(upload) },
|
||||
@@ -1610,8 +1982,8 @@ private fun PendingPhotoTile(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label,
|
||||
color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Ink,
|
||||
upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label,
|
||||
color = if (hasFailureDetails) TppColors.Error else TppColors.Ink,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package pl.firmatpp.kierowca.ui
|
||||
|
||||
import java.io.File
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
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.RoutePhotoDto
|
||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||
|
||||
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||
|
||||
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
|
||||
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
|
||||
|
||||
@@ -18,9 +23,79 @@ fun canCompleteRouteFromDriverApp(
|
||||
canManageRoutePhotos(selectedDate, today)
|
||||
&& 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 =
|
||||
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)
|
||||
|
||||
@@ -38,6 +113,9 @@ fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String
|
||||
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
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ data class DriverUiState(
|
||||
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(),
|
||||
@@ -54,6 +56,7 @@ data class DriverUiState(
|
||||
val isOnline: Boolean = true,
|
||||
val isStale: Boolean = false,
|
||||
val lastSuccessfulSyncAtEpochMillis: Long? = null,
|
||||
val routeDayLiveUpdateMessage: String? = null,
|
||||
val feedback: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -135,7 +138,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
@@ -158,6 +168,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
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,
|
||||
@@ -165,7 +176,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 ->
|
||||
@@ -238,6 +249,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) }
|
||||
}
|
||||
@@ -344,6 +385,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
_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) {
|
||||
@@ -419,8 +465,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
||||
val snapshot = _state.value
|
||||
when (hint.scope) {
|
||||
DriverSyncRepository.SCOPE_ROUTES -> {
|
||||
if (hint.date == null || hint.date == snapshot.selectedDate) {
|
||||
if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) {
|
||||
refreshRoutesSilently()
|
||||
if (snapshot.screen == DriverScreen.Routes) {
|
||||
_state.update {
|
||||
it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DriverSyncWorker.enqueue(getApplication(), hint.date, null)
|
||||
}
|
||||
|
||||
@@ -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="#07172B" />
|
||||
</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>
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,23 @@ class DriverUiRulesTest {
|
||||
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
|
||||
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
|
||||
assertEquals(0, inlinePhotoGridRows(0))
|
||||
@@ -43,6 +60,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))
|
||||
@@ -111,6 +160,16 @@ class DriverUiRulesTest {
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user