Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0115e85fc | ||
|
|
0e15d5f9a4 | ||
|
|
5555676395 | ||
|
|
ca512f5993 | ||
|
|
64d6f5a41d | ||
|
|
a3d39764e2 | ||
|
|
705b8904a4 | ||
|
|
541dda292f |
@@ -33,8 +33,8 @@ android {
|
|||||||
applicationId = "pl.firmatpp.kierowca"
|
applicationId = "pl.firmatpp.kierowca"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 28
|
versionCode = 37
|
||||||
versionName = "1.0.27"
|
versionName = "1.0.36"
|
||||||
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package pl.firmatpp.kierowca
|
package pl.firmatpp.kierowca
|
||||||
|
|
||||||
import android.os.Bundle
|
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.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -9,13 +12,26 @@ import pl.firmatpp.kierowca.ui.DriverViewModel
|
|||||||
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
|
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private var notificationRouteId by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
|
||||||
setContent {
|
setContent {
|
||||||
TppKierowcaTheme {
|
TppKierowcaTheme {
|
||||||
val viewModel: DriverViewModel = viewModel()
|
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 retryable: Boolean,
|
||||||
val code: String? = null,
|
val code: String? = null,
|
||||||
val statusCode: Int? = null,
|
val statusCode: Int? = null,
|
||||||
|
val responseBody: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
object ApiErrorMapper {
|
object ApiErrorMapper {
|
||||||
@@ -48,10 +49,17 @@ object ApiErrorMapper {
|
|||||||
code = code,
|
code = code,
|
||||||
message = message,
|
message = message,
|
||||||
retryable = retryable,
|
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) {
|
val kind = when (statusCode) {
|
||||||
401 -> ApiErrorKind.Auth
|
401 -> ApiErrorKind.Auth
|
||||||
403 -> ApiErrorKind.Forbidden
|
403 -> ApiErrorKind.Forbidden
|
||||||
@@ -80,6 +88,7 @@ object ApiErrorMapper {
|
|||||||
retryable = resolvedRetryable,
|
retryable = resolvedRetryable,
|
||||||
code = code,
|
code = code,
|
||||||
statusCode = statusCode,
|
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.BroadcastAuthBody
|
||||||
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
|
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
|
||||||
import pl.firmatpp.kierowca.data.model.DriverDto
|
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.OtpResponse
|
||||||
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
||||||
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
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.RequestOtpBody
|
||||||
import pl.firmatpp.kierowca.data.model.RouteResponse
|
import pl.firmatpp.kierowca.data.model.RouteResponse
|
||||||
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
||||||
@@ -49,6 +52,9 @@ class DriverRepository(
|
|||||||
suspend fun completeRoute(routeId: String) =
|
suspend fun completeRoute(routeId: String) =
|
||||||
api.completeRoute(authHeader(requireToken()), routeId)
|
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 =
|
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
|
||||||
api.syncState(authHeader(requireToken()), date, routeId)
|
api.syncState(authHeader(requireToken()), date, routeId)
|
||||||
|
|
||||||
@@ -59,6 +65,10 @@ class DriverRepository(
|
|||||||
api.storePushToken(authHeader(requireToken()), PushTokenBody(token))
|
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() {
|
suspend fun deletePushToken() {
|
||||||
val token = tokenStore.read() ?: return
|
val token = tokenStore.read() ?: return
|
||||||
runCatching { api.deletePushToken(authHeader(token)) }
|
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.BroadcastAuthResponse
|
||||||
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
|
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
|
||||||
import pl.firmatpp.kierowca.data.model.OtpResponse
|
import pl.firmatpp.kierowca.data.model.OtpResponse
|
||||||
|
import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
|
||||||
|
import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
|
||||||
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
|
||||||
import pl.firmatpp.kierowca.data.model.PushTokenBody
|
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.RequestOtpBody
|
||||||
import pl.firmatpp.kierowca.data.model.RouteResponse
|
import pl.firmatpp.kierowca.data.model.RouteResponse
|
||||||
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
import pl.firmatpp.kierowca.data.model.SyncStateResponse
|
||||||
@@ -35,6 +38,17 @@ interface MobileDriverApi {
|
|||||||
@POST("mobile/driver/auth/logout")
|
@POST("mobile/driver/auth/logout")
|
||||||
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Boolean>
|
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")
|
@GET("mobile/driver/bootstrap")
|
||||||
suspend fun bootstrap(
|
suspend fun bootstrap(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@@ -72,6 +86,12 @@ interface MobileDriverApi {
|
|||||||
@Body body: PushTokenBody,
|
@Body body: PushTokenBody,
|
||||||
): Map<String, Boolean>
|
): 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")
|
@DELETE("mobile/driver/push-token")
|
||||||
suspend fun deletePushToken(
|
suspend fun deletePushToken(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ data class BootstrapResponse(
|
|||||||
val session: DriverSessionDto,
|
val session: DriverSessionDto,
|
||||||
val routes: RoutesBucketDto,
|
val routes: RoutesBucketDto,
|
||||||
val driverAppSettings: DriverAppSettingsDto?,
|
val driverAppSettings: DriverAppSettingsDto?,
|
||||||
|
val notificationPreferences: NotificationPreferencesDto? = null,
|
||||||
|
val realtime: RealtimeConfigDto? = null,
|
||||||
val syncState: SyncStateResponse? = null,
|
val syncState: SyncStateResponse? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +68,16 @@ data class DriverSessionDto(
|
|||||||
val lastSyncAt: String,
|
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(
|
data class RoutesBucketDto(
|
||||||
val today: List<DriverRouteDto> = emptyList(),
|
val today: List<DriverRouteDto> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -110,8 +122,19 @@ data class PushTokenBody(
|
|||||||
val platform: String = "android",
|
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(
|
data class DriverRouteDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
val routeDate: String? = null,
|
||||||
val startsAt: String,
|
val startsAt: String,
|
||||||
val originName: String,
|
val originName: String,
|
||||||
val destinationName: String,
|
val destinationName: String,
|
||||||
|
|||||||
@@ -39,10 +39,9 @@ interface PhotoUploadDao {
|
|||||||
SELECT * FROM photo_uploads
|
SELECT * FROM photo_uploads
|
||||||
WHERE routeId = :routeId
|
WHERE routeId = :routeId
|
||||||
AND status = 'CONFIRMED'
|
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(
|
@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) {
|
suspend fun discardConfirmedServerPhotos(route: DriverRouteDto) {
|
||||||
val confirmedRequestIds = route.photos.mapNotNull { it.clientRequestId }.distinct()
|
dao.confirmedForRoute(route.id).forEach { upload ->
|
||||||
if (confirmedRequestIds.isEmpty()) return
|
|
||||||
|
|
||||||
dao.confirmedForServerPhotos(route.id, confirmedRequestIds).forEach { upload ->
|
|
||||||
File(upload.localPath).delete()
|
File(upload.localPath).delete()
|
||||||
dao.delete(upload.clientRequestId)
|
dao.delete(upload.clientRequestId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ class PhotoUploadWorker(
|
|||||||
val file = File(upload.localPath)
|
val file = File(upload.localPath)
|
||||||
|
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
|
val message = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane."
|
||||||
dao.updateStatus(
|
dao.updateStatus(
|
||||||
clientRequestId = clientRequestId,
|
clientRequestId = clientRequestId,
|
||||||
status = PhotoUploadStatus.FailedPermanent.storageValue,
|
status = PhotoUploadStatus.FailedPermanent.storageValue,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
lastError = "Lokalny plik zdjęcia nie istnieje. Zdjęcie nie zostało zapisane.",
|
lastError = PhotoUploadFailureDetails.local(message),
|
||||||
attemptIncrement = 0,
|
attemptIncrement = 0,
|
||||||
)
|
)
|
||||||
return Result.failure()
|
return Result.failure()
|
||||||
@@ -68,7 +69,7 @@ class PhotoUploadWorker(
|
|||||||
clientRequestId = clientRequestId,
|
clientRequestId = clientRequestId,
|
||||||
status = status.storageValue,
|
status = status.storageValue,
|
||||||
progress = 0,
|
progress = 0,
|
||||||
lastError = error.message,
|
lastError = PhotoUploadFailureDetails.fromApiError(error),
|
||||||
attemptIncrement = 0,
|
attemptIncrement = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
val data = message.data
|
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
|
if (data["type"] != "driver_sync_hint") return
|
||||||
|
|
||||||
DriverSyncWorker.enqueue(
|
DriverSyncWorker.enqueue(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.google.gson.Gson
|
|||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -14,8 +15,8 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import pl.firmatpp.kierowca.BuildConfig
|
|
||||||
import pl.firmatpp.kierowca.data.DriverRepository
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
|
||||||
|
|
||||||
class DriverLiveSyncClient(
|
class DriverLiveSyncClient(
|
||||||
private val repository: DriverRepository,
|
private val repository: DriverRepository,
|
||||||
@@ -24,18 +25,29 @@ class DriverLiveSyncClient(
|
|||||||
private val client: OkHttpClient = OkHttpClient(),
|
private val client: OkHttpClient = OkHttpClient(),
|
||||||
private val gson: Gson = Gson(),
|
private val gson: Gson = Gson(),
|
||||||
) {
|
) {
|
||||||
|
private companion object {
|
||||||
|
const val HEARTBEAT_INTERVAL_MS = 10_000L
|
||||||
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val started = AtomicBoolean(false)
|
private val started = AtomicBoolean(false)
|
||||||
private var webSocket: WebSocket? = null
|
private var webSocket: WebSocket? = null
|
||||||
private var driverId: String? = null
|
private var driverId: String? = null
|
||||||
|
private var realtimeConfig: RealtimeConfigDto? = null
|
||||||
|
private var socketId: String? = null
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
|
||||||
fun start(driverId: String) {
|
fun start(driverId: String, config: RealtimeConfigDto?) {
|
||||||
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
|
val appKey = config?.reverbAppKey?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
val wsBaseUrl = config.reverbWsBaseUrl?.takeIf { it.isNotBlank() } ?: return
|
||||||
|
if (!config.reverbEnabled) return
|
||||||
|
|
||||||
|
realtimeConfig = config
|
||||||
this.driverId = driverId
|
this.driverId = driverId
|
||||||
if (!started.compareAndSet(false, true)) return
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
|
||||||
val wsUrl = BuildConfig.REVERB_WS_BASE_URL.trimEnd('/') +
|
val wsUrl = wsBaseUrl.trimEnd('/') +
|
||||||
"/" + BuildConfig.REVERB_APP_KEY +
|
"/" + appKey +
|
||||||
"?protocol=7&client=android&version=1.0&flash=false"
|
"?protocol=7&client=android&version=1.0&flash=false"
|
||||||
|
|
||||||
webSocket = client.newWebSocket(
|
webSocket = client.newWebSocket(
|
||||||
@@ -46,11 +58,15 @@ class DriverLiveSyncClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
stopHeartbeat()
|
||||||
|
reportRealtimeStatus("disconnected", reason.takeIf { it.isNotBlank() })
|
||||||
started.set(false)
|
started.set(false)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: okhttp3.Response?) {
|
||||||
|
stopHeartbeat()
|
||||||
|
reportRealtimeStatus("error", t.message ?: response?.message)
|
||||||
started.set(false)
|
started.set(false)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
}
|
}
|
||||||
@@ -60,9 +76,13 @@ class DriverLiveSyncClient(
|
|||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
started.set(false)
|
started.set(false)
|
||||||
|
stopHeartbeat()
|
||||||
|
reportRealtimeStatus("disconnected", "client_stop")
|
||||||
webSocket?.close(1000, "logout")
|
webSocket?.close(1000, "logout")
|
||||||
webSocket = null
|
webSocket = null
|
||||||
driverId = null
|
driverId = null
|
||||||
|
realtimeConfig = null
|
||||||
|
socketId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
@@ -76,10 +96,18 @@ class DriverLiveSyncClient(
|
|||||||
|
|
||||||
when (event) {
|
when (event) {
|
||||||
"pusher:connection_established" -> {
|
"pusher:connection_established" -> {
|
||||||
val socketId = root.dataObject()?.string("socket_id") ?: return
|
socketId = root.dataObject()?.string("socket_id") ?: return
|
||||||
subscribe(socket, socketId)
|
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()
|
onConnected()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
|
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +156,30 @@ class DriverLiveSyncClient(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
delay(5_000)
|
delay(5_000)
|
||||||
if (!started.get() && driverId == id) {
|
if (!started.get() && driverId == id) {
|
||||||
start(id)
|
start(id, realtimeConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
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.CloudUpload
|
||||||
import androidx.compose.material.icons.outlined.Factory
|
import androidx.compose.material.icons.outlined.Factory
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
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.LocationOn
|
||||||
import androidx.compose.material.icons.outlined.LocalShipping
|
import androidx.compose.material.icons.outlined.LocalShipping
|
||||||
import androidx.compose.material.icons.outlined.Navigation
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -103,12 +106,14 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -137,10 +142,20 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper
|
|||||||
import pl.firmatpp.kierowca.ui.theme.TppColors
|
import pl.firmatpp.kierowca.ui.theme.TppColors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DriverApp(viewModel: DriverViewModel) {
|
fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val context = LocalContext.current
|
||||||
var appInForeground by remember { mutableStateOf(true) }
|
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) {
|
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
|
||||||
viewModel.back()
|
viewModel.back()
|
||||||
@@ -172,6 +187,9 @@ fun DriverApp(viewModel: DriverViewModel) {
|
|||||||
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
|
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
|
||||||
onCode = viewModel::updateOtpCode,
|
onCode = viewModel::updateOtpCode,
|
||||||
)
|
)
|
||||||
|
LaunchedEffect(initialRouteId) {
|
||||||
|
viewModel.openRouteFromNotification(initialRouteId)
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
|
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
|
||||||
when (state.screen) {
|
when (state.screen) {
|
||||||
@@ -185,8 +203,26 @@ fun DriverApp(viewModel: DriverViewModel) {
|
|||||||
onProfile = viewModel::openProfile,
|
onProfile = viewModel::openProfile,
|
||||||
onPhotoQueue = viewModel::openPhotoQueue,
|
onPhotoQueue = viewModel::openPhotoQueue,
|
||||||
onRoute = viewModel::openRoute,
|
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(
|
DriverScreen.Detail -> DetailScreen(
|
||||||
state,
|
state,
|
||||||
viewModel::back,
|
viewModel::back,
|
||||||
@@ -216,25 +252,29 @@ fun DriverApp(viewModel: DriverViewModel) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StartupScreen() {
|
private fun StartupScreen() {
|
||||||
Column(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.background(TppColors.Surface),
|
.background(TppColors.Surface),
|
||||||
|
) {
|
||||||
|
val bannerHeight = authBrandBannerHeightDp(maxHeight.value.toInt()).dp
|
||||||
|
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||||
BrandBannerHeader(height = 178.dp)
|
BrandBannerHeader(height = bannerHeight)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 32.dp),
|
.padding(horizontal = horizontalPadding),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
CircularProgressIndicator(color = TppColors.Forest)
|
CircularProgressIndicator(color = TppColors.Forest)
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -245,25 +285,37 @@ private fun StartupScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||||
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
|
||||||
Column(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(TppColors.Surface)
|
.background(TppColors.Surface)
|
||||||
.navigationBarsPadding(),
|
.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()) {
|
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||||
BrandBannerHeader(height = 190.dp)
|
BrandBannerHeader(height = bannerHeight)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.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,
|
horizontalAlignment = Alignment.Start,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -287,14 +339,14 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
|||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(if (screenHeightDp < 640) 18.dp else 28.dp))
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
|
||||||
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
|
border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
|
||||||
shape = RoundedCornerShape(8.dp),
|
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(
|
Text(
|
||||||
"Numer telefonu",
|
"Numer telefonu",
|
||||||
color = TppColors.Ink,
|
color = TppColors.Ink,
|
||||||
@@ -304,6 +356,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
|||||||
)
|
)
|
||||||
PhoneNumberField(
|
PhoneNumberField(
|
||||||
digits = phoneDigits,
|
digits = phoneDigits,
|
||||||
|
screenWidthDp = screenWidthDp,
|
||||||
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
|
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -333,6 +386,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -376,23 +430,29 @@ private fun BrandBannerHeader(height: Dp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(82.dp)
|
.height(fieldHeight)
|
||||||
.background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp))
|
.background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp))
|
||||||
.border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)),
|
.border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.width(94.dp)
|
.width(prefixWidth)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.background(TppColors.Panel),
|
.background(TppColors.Panel),
|
||||||
contentAlignment = Alignment.Center,
|
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))
|
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
@@ -403,19 +463,19 @@ private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
|
|||||||
visualTransformation = PolishPhoneNumberVisualTransformation,
|
visualTransformation = PolishPhoneNumberVisualTransformation,
|
||||||
textStyle = TextStyle(
|
textStyle = TextStyle(
|
||||||
color = TppColors.Muted,
|
color = TppColors.Muted,
|
||||||
fontSize = 28.sp,
|
fontSize = inputFontSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = inputPadding),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
if (digits.isBlank()) {
|
if (digits.isBlank()) {
|
||||||
Text(
|
Text(
|
||||||
"000 000 000",
|
"000 000 000",
|
||||||
color = TppColors.Muted.copy(alpha = 0.92f),
|
color = TppColors.Muted.copy(alpha = 0.92f),
|
||||||
fontSize = 28.sp,
|
fontSize = inputFontSize,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -450,15 +510,32 @@ private fun OtpScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit) {
|
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(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(20.dp),
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = 20.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
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(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))
|
Text(subtitle, color = TppColors.Muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp))
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||||
@@ -470,12 +547,37 @@ private fun RoutesScreen(
|
|||||||
onProfile: () -> Unit,
|
onProfile: () -> Unit,
|
||||||
onPhotoQueue: () -> Unit,
|
onPhotoQueue: () -> Unit,
|
||||||
onRoute: (String) -> 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(
|
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 = {
|
bottomBar = {
|
||||||
StitchBottomBar(
|
StitchBottomBar(
|
||||||
activeScreen = DriverScreen.Routes,
|
activeScreen = DriverScreen.Routes,
|
||||||
|
height = bottomNavigationHeightDp(screenHeightDp).dp,
|
||||||
onRoutes = onRefresh,
|
onRoutes = onRefresh,
|
||||||
onProfile = onProfile,
|
onProfile = onProfile,
|
||||||
)
|
)
|
||||||
@@ -486,10 +588,21 @@ private fun RoutesScreen(
|
|||||||
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize(),
|
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),
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
) {
|
) {
|
||||||
item {
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -504,6 +617,7 @@ private fun RoutesScreen(
|
|||||||
DateChip(state.selectedDate)
|
DateChip(state.selectedDate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
RouteDateSelector(
|
RouteDateSelector(
|
||||||
selectedDate = state.selectedDate,
|
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
|
@Composable
|
||||||
@@ -580,13 +726,16 @@ private fun PhotoQueueScreen(
|
|||||||
) {
|
) {
|
||||||
val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis }
|
val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis }
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") },
|
topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") },
|
||||||
containerColor = TppColors.Surface,
|
containerColor = TppColors.Surface,
|
||||||
) { padding ->
|
) { padding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize().padding(padding),
|
Modifier.fillMaxSize().padding(padding),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
) {
|
) {
|
||||||
item { OfflineStaleBanner(state) }
|
item { OfflineStaleBanner(state) }
|
||||||
@@ -607,11 +756,14 @@ private fun PhotoQueueScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploadEntity) -> Unit) {
|
private fun PhotoQueueItem(upload: PhotoUploadEntity, onRetryUpload: (PhotoUploadEntity) -> Unit) {
|
||||||
val status = upload.statusType
|
val status = upload.statusType
|
||||||
|
val hasFailureDetails = canShowPhotoUploadFailureDetails(upload.status, upload.lastError)
|
||||||
|
var showFailureDetails by remember { mutableStateOf(false) }
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||||
border = BorderStroke(1.dp, TppColors.Outline),
|
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)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold)
|
Text("Kurs #${upload.routeId}", color = TppColors.Ink, fontWeight = FontWeight.Bold)
|
||||||
Text(
|
Text(
|
||||||
upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label,
|
upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label,
|
||||||
color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Muted,
|
color = if (hasFailureDetails) TppColors.Error else TppColors.Muted,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
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)) {
|
if (canRetryPhotoUpload(upload.status)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onRetryUpload(upload) },
|
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
|
@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()) {
|
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)
|
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),
|
shape = RoundedCornerShape(8.dp),
|
||||||
border = BorderStroke(1.dp, TppColors.Outline),
|
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)) {
|
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(8.dp)
|
.width(6.dp)
|
||||||
.background(stripColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)),
|
.background(stripColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)),
|
||||||
)
|
)
|
||||||
Column(Modifier.fillMaxWidth()) {
|
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(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -780,7 +997,8 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
|||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(26.dp))
|
}
|
||||||
|
Spacer(Modifier.height(if (compactWidth) 18.dp else 26.dp))
|
||||||
RouteTimeline(display.origin, display.destination)
|
RouteTimeline(display.origin, display.destination)
|
||||||
}
|
}
|
||||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.55f))
|
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.55f))
|
||||||
@@ -795,6 +1013,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -812,7 +1031,7 @@ private fun StatusChip(status: String, color: Color) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RouteTimeline(origin: String, destination: String) {
|
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) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Box(Modifier.size(18.dp).background(Color.White, RoundedCornerShape(50)).padding(3.dp)) {
|
Box(Modifier.size(18.dp).background(Color.White, RoundedCornerShape(50)).padding(3.dp)) {
|
||||||
Box(Modifier.fillMaxSize().background(TppColors.Forest, RoundedCornerShape(50)))
|
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.width(3.dp).height(64.dp).background(TppColors.Outline.copy(alpha = 0.8f)))
|
||||||
Box(Modifier.size(18.dp).background(TppColors.Navy, RoundedCornerShape(50)))
|
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 {
|
Column {
|
||||||
Text("SKĄD", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
Text("SKĄD", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
||||||
Text(origin, style = MaterialTheme.typography.titleLarge, color = TppColors.Ink, fontWeight = FontWeight.Bold)
|
Text(origin, style = MaterialTheme.typography.titleLarge, color = TppColors.Ink, fontWeight = FontWeight.Bold)
|
||||||
@@ -839,12 +1058,20 @@ private fun ProfileScreen(
|
|||||||
onRoutes: () -> Unit,
|
onRoutes: () -> Unit,
|
||||||
onProfile: () -> Unit,
|
onProfile: () -> Unit,
|
||||||
onLogout: () -> 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(
|
Scaffold(
|
||||||
topBar = { StitchHeader() },
|
topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
StitchBottomBar(
|
StitchBottomBar(
|
||||||
activeScreen = DriverScreen.Profile,
|
activeScreen = DriverScreen.Profile,
|
||||||
|
height = bottomNavigationHeightDp(screenHeightDp).dp,
|
||||||
onRoutes = onRoutes,
|
onRoutes = onRoutes,
|
||||||
onProfile = onProfile,
|
onProfile = onProfile,
|
||||||
)
|
)
|
||||||
@@ -855,7 +1082,8 @@ private fun ProfileScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = horizontalPadding, vertical = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
) {
|
) {
|
||||||
Card(
|
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(
|
Button(
|
||||||
onClick = onLogout,
|
onClick = onLogout,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
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
|
@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()) {
|
Column(Modifier.fillMaxWidth().background(Color.White).navigationBarsPadding()) {
|
||||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.9f), thickness = 2.dp)
|
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.9f), thickness = 2.dp)
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().height(82.dp),
|
Modifier.fillMaxWidth().height(height),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -974,6 +1272,9 @@ private fun DetailScreen(
|
|||||||
val route = state.selectedRoute
|
val route = state.selectedRoute
|
||||||
val canManagePhotos = canManageRoutePhotos(state.selectedDate)
|
val canManagePhotos = canManageRoutePhotos(state.selectedDate)
|
||||||
|
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
DetailHeader(onBack)
|
DetailHeader(onBack)
|
||||||
@@ -984,10 +1285,10 @@ private fun DetailScreen(
|
|||||||
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
|
item { ManifestSection(route, selectedDate = state.selectedDate, onNavigate = { openNavigation(context, it) }) }
|
||||||
item { OfflineStaleBanner(state) }
|
item { OfflineStaleBanner(state) }
|
||||||
item {
|
item {
|
||||||
RouteCompletionSection(
|
RouteCompletionSection(
|
||||||
@@ -1041,6 +1342,7 @@ private fun DetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showCompleteDialog && route != null) {
|
if (showCompleteDialog && route != null) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -1113,7 +1415,9 @@ private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy"
|
|||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = TppColors.Ink,
|
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)
|
HorizontalDivider(color = TppColors.Outline, thickness = 1.dp)
|
||||||
@@ -1155,17 +1459,11 @@ private fun RouteCompletionSection(
|
|||||||
|
|
||||||
if (!canCompleteRouteFromDriverApp(route, selectedDate)) return
|
if (!canCompleteRouteFromDriverApp(route, selectedDate)) return
|
||||||
|
|
||||||
Card(
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
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)) {
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onComplete,
|
onClick = onComplete,
|
||||||
enabled = isOnline && !completing,
|
enabled = isOnline && !completing,
|
||||||
modifier = Modifier.fillMaxWidth().height(58.dp),
|
modifier = Modifier.fillMaxWidth().height(64.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = TppColors.Forest,
|
containerColor = TppColors.Forest,
|
||||||
disabledContainerColor = TppColors.Outline,
|
disabledContainerColor = TppColors.Outline,
|
||||||
@@ -1194,10 +1492,10 @@ private fun RouteCompletionSection(
|
|||||||
color = TppColors.Muted,
|
color = TppColors.Muted,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun photoCountLabel(count: Int): String =
|
private fun photoCountLabel(count: Int): String =
|
||||||
@@ -1208,8 +1506,9 @@ private fun photoCountLabel(count: Int): String =
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointDto) -> Unit) {
|
private fun ManifestSection(route: DriverRouteDto, selectedDate: String, onNavigate: (NavigationPointDto) -> Unit) {
|
||||||
val stripColor = routeStatusColor(route.status)
|
val stripColor = routeStatusColor(route.status)
|
||||||
|
val routeDate = route.routeDate ?: selectedDate
|
||||||
Card(
|
Card(
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
@@ -1217,13 +1516,17 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
|
|
||||||
StatusChip(route.status, stripColor)
|
StatusChip(route.status, stripColor)
|
||||||
|
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
|
||||||
|
RouteDateStatusChip(routeDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
"Zlecenie #${route.contractCode ?: route.id}",
|
"Zlecenie #${route.contractCode ?: route.id}",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
@@ -1232,7 +1535,6 @@ private fun ManifestSection(route: DriverRouteDto, onNavigate: (NavigationPointD
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
|
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.65f))
|
||||||
RoutePointBlock(
|
RoutePointBlock(
|
||||||
label = "Załadunek",
|
label = "Załadunek",
|
||||||
@@ -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
|
@Composable
|
||||||
private fun RoutePointBlock(
|
private fun RoutePointBlock(
|
||||||
label: String,
|
label: String,
|
||||||
@@ -1275,14 +1603,14 @@ private fun RoutePointBlock(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(20.dp),
|
Modifier.fillMaxWidth().padding(20.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier.size(48.dp).background(iconBackground, RoundedCornerShape(24.dp)),
|
Modifier.size(40.dp).background(iconBackground, RoundedCornerShape(20.dp)),
|
||||||
contentAlignment = Alignment.Center,
|
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)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
@@ -1307,9 +1635,9 @@ private fun RoutePointBlock(
|
|||||||
if (navigationPoint?.hasCoordinates() == true) {
|
if (navigationPoint?.hasCoordinates() == true) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onNavigate(navigationPoint) },
|
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
|
@Composable
|
||||||
private fun RouteFactsFooter(route: DriverRouteDto) {
|
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()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.weight(1f))
|
RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.weight(1f))
|
||||||
RouteFact("Pojazd", route.truckLabel(), 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))
|
RouteFact("Dystans", route.distanceLabel(), Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -1409,7 +1748,12 @@ private fun PhotoGrid(
|
|||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
border = BorderStroke(1.dp, TppColors.Outline),
|
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(
|
Text(
|
||||||
"Załączone zdjęcia ($visibleAttachmentCount)",
|
"Załączone zdjęcia ($visibleAttachmentCount)",
|
||||||
color = TppColors.Muted,
|
color = TppColors.Muted,
|
||||||
@@ -1419,11 +1763,11 @@ private fun PhotoGrid(
|
|||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
EmptyPhotoState()
|
EmptyPhotoState()
|
||||||
} else {
|
} else {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(gridGap)) {
|
||||||
repeat(inlinePhotoGridRows(items.size)) { rowIndex ->
|
repeat(inlinePhotoGridRows(items.size)) { rowIndex ->
|
||||||
val startIndex = rowIndex * 2
|
val startIndex = rowIndex * 2
|
||||||
val rowItems = items.subList(startIndex, minOf(startIndex + 2, items.size))
|
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 ->
|
rowItems.forEach { item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is PhotoGridItem.Server -> PhotoTile(
|
is PhotoGridItem.Server -> PhotoTile(
|
||||||
@@ -1453,6 +1797,7 @@ private fun PhotoGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pendingDelete?.let { target ->
|
pendingDelete?.let { target ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -1551,6 +1896,14 @@ private fun PendingPhotoTile(
|
|||||||
) {
|
) {
|
||||||
val status = upload.statusType
|
val status = upload.statusType
|
||||||
val canDeleteServerPhoto = confirmedUploadServerPhotoId(upload.status, upload.serverPhotoId) != null
|
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))) {
|
Box(modifier.aspectRatio(1f).background(TppColors.Panel, RoundedCornerShape(4.dp))) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = File(upload.localPath),
|
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)) {
|
if (canRetryPhotoUpload(upload.status)) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onRetry(upload) },
|
onClick = { onRetry(upload) },
|
||||||
@@ -1610,8 +1982,8 @@ private fun PendingPhotoTile(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
upload.lastError?.takeIf { status == PhotoUploadStatus.FailedPermanent } ?: status.label,
|
upload.lastError?.lineSequence()?.firstOrNull { it.isNotBlank() }?.takeIf { hasFailureDetails } ?: status.label,
|
||||||
color = if (status == PhotoUploadStatus.FailedPermanent) TppColors.Error else TppColors.Ink,
|
color = if (hasFailureDetails) TppColors.Error else TppColors.Ink,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package pl.firmatpp.kierowca.ui
|
package pl.firmatpp.kierowca.ui
|
||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
|
||||||
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
import pl.firmatpp.kierowca.data.upload.PhotoUploadEntity
|
||||||
|
|
||||||
|
private val shortDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||||
|
|
||||||
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
|
fun canManageRoutePhotos(selectedDate: String, today: LocalDate = LocalDate.now()): Boolean =
|
||||||
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
|
runCatching { LocalDate.parse(selectedDate).isEqual(today) }.getOrDefault(false)
|
||||||
|
|
||||||
@@ -18,9 +23,79 @@ fun canCompleteRouteFromDriverApp(
|
|||||||
canManageRoutePhotos(selectedDate, today)
|
canManageRoutePhotos(selectedDate, today)
|
||||||
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
|
&& route.status in setOf("ZAPLANOWANA", "W TRAKCIE")
|
||||||
|
|
||||||
|
fun shouldShowRouteDayLiveUpdate(viewedDate: String, hintDate: String?): Boolean =
|
||||||
|
viewedDate.isNotBlank() && (hintDate == null || hintDate == viewedDate)
|
||||||
|
|
||||||
|
fun routeDateChipLabel(routeDate: String?, today: LocalDate = LocalDate.now()): String {
|
||||||
|
val date = routeDate
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
|
?: return "—"
|
||||||
|
|
||||||
|
val fallback = date.format(shortDateFormatter)
|
||||||
|
val days = ChronoUnit.DAYS.between(today, date)
|
||||||
|
val human = when {
|
||||||
|
days == 0L -> "dzisiaj"
|
||||||
|
days == 1L -> "jutro"
|
||||||
|
days in 2L..7L -> polishWeekdayPhrase(date.dayOfWeek)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
return human?.let { "$it · $fallback" } ?: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun polishWeekdayPhrase(day: DayOfWeek): String =
|
||||||
|
when (day) {
|
||||||
|
DayOfWeek.MONDAY -> "w poniedziałek"
|
||||||
|
DayOfWeek.TUESDAY -> "we wtorek"
|
||||||
|
DayOfWeek.WEDNESDAY -> "w środę"
|
||||||
|
DayOfWeek.THURSDAY -> "w czwartek"
|
||||||
|
DayOfWeek.FRIDAY -> "w piątek"
|
||||||
|
DayOfWeek.SATURDAY -> "w sobotę"
|
||||||
|
DayOfWeek.SUNDAY -> "w niedzielę"
|
||||||
|
}
|
||||||
|
|
||||||
fun inlinePhotoGridRows(photoCount: Int): Int =
|
fun inlinePhotoGridRows(photoCount: Int): Int =
|
||||||
if (photoCount <= 0) 0 else (photoCount + 1) / 2
|
if (photoCount <= 0) 0 else (photoCount + 1) / 2
|
||||||
|
|
||||||
|
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 =
|
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
|
||||||
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
|
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
|
||||||
|
|
||||||
@@ -38,6 +113,9 @@ fun confirmedUploadServerPhotoId(status: String, serverPhotoId: String?): String
|
|||||||
fun canRetryPhotoUpload(status: String): Boolean =
|
fun canRetryPhotoUpload(status: String): Boolean =
|
||||||
status == "FAILED_RETRYABLE"
|
status == "FAILED_RETRYABLE"
|
||||||
|
|
||||||
|
fun canShowPhotoUploadFailureDetails(status: String, lastError: String?): Boolean =
|
||||||
|
status in setOf("FAILED_RETRYABLE", "FAILED_PERMANENT") && !lastError.isNullOrBlank()
|
||||||
|
|
||||||
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
|
fun localUploadPreviewPhoto(upload: PhotoUploadEntity): RoutePhotoDto? {
|
||||||
if (upload.localPath.isBlank()) return null
|
if (upload.localPath.isBlank()) return null
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ data class DriverUiState(
|
|||||||
val allowGalleryUploads: Boolean = true,
|
val allowGalleryUploads: Boolean = true,
|
||||||
val allowRouteCompletion: Boolean = false,
|
val allowRouteCompletion: Boolean = false,
|
||||||
val requirePreciseLocationForPhotos: Boolean = false,
|
val requirePreciseLocationForPhotos: Boolean = false,
|
||||||
|
val notifyNewRoutes: Boolean = false,
|
||||||
|
val notificationPermissionDenied: Boolean = false,
|
||||||
val selectedRoute: DriverRouteDto? = null,
|
val selectedRoute: DriverRouteDto? = null,
|
||||||
val selectedPhoto: RoutePhotoDto? = null,
|
val selectedPhoto: RoutePhotoDto? = null,
|
||||||
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
val photoUploads: List<PhotoUploadEntity> = emptyList(),
|
||||||
@@ -54,6 +56,7 @@ data class DriverUiState(
|
|||||||
val isOnline: Boolean = true,
|
val isOnline: Boolean = true,
|
||||||
val isStale: Boolean = false,
|
val isStale: Boolean = false,
|
||||||
val lastSuccessfulSyncAtEpochMillis: Long? = null,
|
val lastSuccessfulSyncAtEpochMillis: Long? = null,
|
||||||
|
val routeDayLiveUpdateMessage: String? = null,
|
||||||
val feedback: String? = null,
|
val feedback: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
@@ -135,7 +138,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
|
|
||||||
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
|
fun refreshRoutesSilently() = loadRoutes(date = _state.value.selectedDate, showLoading = false, navigateToRoutes = false)
|
||||||
|
|
||||||
fun selectRouteDate(date: String) = loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
|
fun selectRouteDate(date: String) {
|
||||||
|
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
|
||||||
|
loadRoutes(date = date, showLoading = true, navigateToRoutes = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRouteDayLiveUpdate() {
|
||||||
|
_state.update { it.copy(routeDayLiveUpdateMessage = null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
|
private fun loadRoutes(date: String?, showLoading: Boolean, navigateToRoutes: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -158,6 +168,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
|
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
|
||||||
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
|
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
|
||||||
?: it.requirePreciseLocationForPhotos,
|
?: it.requirePreciseLocationForPhotos,
|
||||||
|
notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
|
||||||
imageAuthHeader = repository.imageAuthHeader(),
|
imageAuthHeader = repository.imageAuthHeader(),
|
||||||
isStale = cached.stale || !it.isOnline,
|
isStale = cached.stale || !it.isOnline,
|
||||||
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
|
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
|
||||||
@@ -165,7 +176,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
response.session.driver.id.let { driverId ->
|
response.session.driver.id.let { driverId ->
|
||||||
liveSyncClient.start(driverId)
|
liveSyncClient.start(driverId, response.realtime)
|
||||||
registerPushTokenIfAvailable(driverId)
|
registerPushTokenIfAvailable(driverId)
|
||||||
}
|
}
|
||||||
}.onFailure { throwable ->
|
}.onFailure { throwable ->
|
||||||
@@ -238,6 +249,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
|
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun 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() {
|
fun openPhotoQueue() {
|
||||||
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
|
_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) }
|
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openRouteFromNotification(routeId: String?) {
|
||||||
|
if (routeId.isNullOrBlank()) return
|
||||||
|
openRoute(routeId)
|
||||||
|
}
|
||||||
|
|
||||||
fun back() {
|
fun back() {
|
||||||
_state.update {
|
_state.update {
|
||||||
when (it.screen) {
|
when (it.screen) {
|
||||||
@@ -419,8 +465,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
when (hint.scope) {
|
when (hint.scope) {
|
||||||
DriverSyncRepository.SCOPE_ROUTES -> {
|
DriverSyncRepository.SCOPE_ROUTES -> {
|
||||||
if (hint.date == null || hint.date == snapshot.selectedDate) {
|
if (shouldShowRouteDayLiveUpdate(snapshot.selectedDate, hint.date)) {
|
||||||
refreshRoutesSilently()
|
refreshRoutesSilently()
|
||||||
|
if (snapshot.screen == DriverScreen.Routes) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(routeDayLiveUpdateMessage = "Spedytor zaktualizował zlecenia dla tego dnia.")
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
DriverSyncWorker.enqueue(getApplication(), hint.date, null)
|
DriverSyncWorker.enqueue(getApplication(), hint.date, null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
<vector
|
||||||
<solid android:color="#07172B" />
|
android:height="108dp"
|
||||||
</shape>
|
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)
|
assertTrue(error.retryable)
|
||||||
assertEquals("Serwer nie potwierdził operacji. Aplikacja spróbuje ponownie.", error.message)
|
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))
|
assertFalse(canCompleteRouteFromDriverApp(route(status = "ZAPLANOWANA"), "2026-06-29", today))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsLiveRouteDayUpdateOnlyForViewedDate() {
|
||||||
|
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-12"))
|
||||||
|
assertTrue(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = null))
|
||||||
|
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "2026-03-12", hintDate = "2026-03-13"))
|
||||||
|
assertFalse(shouldShowRouteDayLiveUpdate(viewedDate = "", hintDate = "2026-03-12"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun formatsRouteDateChipHumanFirstWithShortFallback() {
|
||||||
|
assertEquals("dzisiaj · 30.06", routeDateChipLabel("2026-06-30", today))
|
||||||
|
assertEquals("jutro · 01.07", routeDateChipLabel("2026-07-01", today))
|
||||||
|
assertEquals("w poniedziałek · 06.07", routeDateChipLabel("2026-07-06", today))
|
||||||
|
assertEquals("02.03", routeDateChipLabel("2027-03-02", today))
|
||||||
|
assertEquals("—", routeDateChipLabel("", today))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
|
fun calculatesPhotoGridRowsForTwoColumnInlineGallery() {
|
||||||
assertEquals(0, inlinePhotoGridRows(0))
|
assertEquals(0, inlinePhotoGridRows(0))
|
||||||
@@ -43,6 +60,38 @@ class DriverUiRulesTest {
|
|||||||
assertEquals(3, inlinePhotoGridRows(5))
|
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
|
@Test
|
||||||
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
|
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
|
||||||
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))
|
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))
|
||||||
@@ -111,6 +160,16 @@ class DriverUiRulesTest {
|
|||||||
assertFalse(canRetryPhotoUpload("CANCELLED"))
|
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
|
@Test
|
||||||
fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() {
|
fun createsFullScreenPreviewForLocalUploadBeforeRouteRefresh() {
|
||||||
val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))
|
val photo = localUploadPreviewPhoto(upload(status = "CONFIRMED", serverPhotoId = "55"))
|
||||||
|
|||||||
Reference in New Issue
Block a user