Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 = 33
|
||||||
versionName = "1.0.27"
|
versionName = "1.0.32"
|
||||||
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,7 @@ 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 syncState: SyncStateResponse? = null,
|
val syncState: SyncStateResponse? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +67,10 @@ data class DriverSessionDto(
|
|||||||
val lastSyncAt: String,
|
val lastSyncAt: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class NotificationPreferencesDto(
|
||||||
|
val notifyNewRoutes: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
data class RoutesBucketDto(
|
data class RoutesBucketDto(
|
||||||
val today: List<DriverRouteDto> = emptyList(),
|
val today: List<DriverRouteDto> = emptyList(),
|
||||||
)
|
)
|
||||||
@@ -110,8 +115,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
|
||||||
@@ -28,6 +29,8 @@ class DriverLiveSyncClient(
|
|||||||
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 socketId: String? = null
|
||||||
|
private var heartbeatJob: Job? = null
|
||||||
|
|
||||||
fun start(driverId: String) {
|
fun start(driverId: String) {
|
||||||
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
|
if (BuildConfig.REVERB_APP_KEY.isBlank()) return
|
||||||
@@ -46,11 +49,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 +67,12 @@ 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
|
||||||
|
socketId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
@@ -76,9 +86,17 @@ 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)
|
||||||
onConnected()
|
}
|
||||||
|
"pusher_internal:subscription_succeeded" -> {
|
||||||
|
val channel = root.string("channel")
|
||||||
|
val id = driverId ?: return
|
||||||
|
if (channel == "private-driver-mobile.$id") {
|
||||||
|
reportRealtimeStatus("connected")
|
||||||
|
startHeartbeat()
|
||||||
|
onConnected()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
|
"DriverMobileSyncHint" -> parseHint(root.dataObject())?.let(onHint)
|
||||||
}
|
}
|
||||||
@@ -132,4 +150,27 @@ class DriverLiveSyncClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = scope.launch {
|
||||||
|
while (started.get()) {
|
||||||
|
reportRealtimeStatus("connected")
|
||||||
|
delay(30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopHeartbeat() {
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reportRealtimeStatus(status: String, error: String? = null) {
|
||||||
|
scope.launch {
|
||||||
|
runCatching {
|
||||||
|
repository.storeRealtimeStatus(status, socketId, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package pl.firmatpp.kierowca.sync
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.Locale
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||||
|
|
||||||
|
object NewRouteNotificationFormatter {
|
||||||
|
private val polishLocale = Locale("pl", "PL")
|
||||||
|
private val monthFormatter = DateTimeFormatter.ofPattern("d MMMM", polishLocale)
|
||||||
|
|
||||||
|
fun title(routeDate: String?, today: LocalDate = LocalDate.now()): String {
|
||||||
|
val label = humanDate(routeDate, today) ?: return "Nowy kurs"
|
||||||
|
|
||||||
|
return "Nowy kurs: $label"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collapsedText(route: DriverRouteDto): String =
|
||||||
|
listOf(route.originName, route.destinationName)
|
||||||
|
.filter(String::isNotBlank)
|
||||||
|
.joinToString(" → ")
|
||||||
|
.ifBlank { route.relationLabel }
|
||||||
|
.let { relation ->
|
||||||
|
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode
|
||||||
|
if (contract.isNullOrBlank()) relation else "$relation · $contract"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun expandedText(route: DriverRouteDto): String {
|
||||||
|
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode ?: "Brak nazwy kontraktu"
|
||||||
|
|
||||||
|
return listOf(
|
||||||
|
"Skąd: ${route.originName.ifBlank { "Nie podano" }}",
|
||||||
|
"Dokąd: ${route.destinationName.ifBlank { "Nie podano" }}",
|
||||||
|
"Kontrakt: $contract",
|
||||||
|
).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun humanDate(routeDate: String?, today: LocalDate): String? {
|
||||||
|
val date = runCatching { routeDate?.let(LocalDate::parse) }.getOrNull() ?: return null
|
||||||
|
|
||||||
|
return when {
|
||||||
|
date == today -> "dzisiaj"
|
||||||
|
date == today.plusDays(1) -> "jutro"
|
||||||
|
date.isAfter(today.plusDays(1)) && !date.isAfter(today.plusDays(7)) ->
|
||||||
|
"w ${weekdayAccusative(date.dayOfWeek)}"
|
||||||
|
else -> date.format(monthFormatter).lowercase(polishLocale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weekdayAccusative(dayOfWeek: DayOfWeek): String =
|
||||||
|
when (dayOfWeek) {
|
||||||
|
DayOfWeek.MONDAY -> "poniedziałek"
|
||||||
|
DayOfWeek.TUESDAY -> "wtorek"
|
||||||
|
DayOfWeek.WEDNESDAY -> "środę"
|
||||||
|
DayOfWeek.THURSDAY -> "czwartek"
|
||||||
|
DayOfWeek.FRIDAY -> "piątek"
|
||||||
|
DayOfWeek.SATURDAY -> "sobotę"
|
||||||
|
DayOfWeek.SUNDAY -> "niedzielę"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package pl.firmatpp.kierowca.sync
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import pl.firmatpp.kierowca.MainActivity
|
||||||
|
import pl.firmatpp.kierowca.R
|
||||||
|
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||||
|
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||||
|
import pl.firmatpp.kierowca.data.DriverRepository
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
class NewRouteNotificationWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
private val repository = DriverRepository(appContext)
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val route = repository.route(routeId).route
|
||||||
|
if (!canShowNotifications(applicationContext)) {
|
||||||
|
return@runCatching Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
createChannel(applicationContext)
|
||||||
|
val contentTitle = NewRouteNotificationFormatter.title(route.routeDate ?: inputData.getString(KEY_DATE))
|
||||||
|
val contentText = NewRouteNotificationFormatter.collapsedText(route)
|
||||||
|
val expandedText = NewRouteNotificationFormatter.expandedText(route)
|
||||||
|
val intent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra(MainActivity.EXTRA_ROUTE_ID, route.id)
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
route.id.hashCode(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(contentTitle)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedText))
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
|
||||||
|
Result.success()
|
||||||
|
}.getOrElse { throwable ->
|
||||||
|
if (throwable is HttpException && throwable.code() == 404) {
|
||||||
|
Result.success()
|
||||||
|
} else {
|
||||||
|
val error = ApiErrorMapper.map(throwable)
|
||||||
|
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Nowe kursy",
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
).apply {
|
||||||
|
description = "Powiadomienia o nowych kursach dodanych przez spedytora."
|
||||||
|
}
|
||||||
|
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canShowNotifications(context: Context): Boolean {
|
||||||
|
val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "new_routes"
|
||||||
|
private const val KEY_ROUTE_ID = "routeId"
|
||||||
|
private const val KEY_DATE = "date"
|
||||||
|
|
||||||
|
fun enqueue(context: Context, routeId: String?, date: String?) {
|
||||||
|
if (routeId.isNullOrBlank()) return
|
||||||
|
|
||||||
|
val request = OneTimeWorkRequestBuilder<NewRouteNotificationWorker>()
|
||||||
|
.setInputData(workDataOf(KEY_ROUTE_ID to routeId, KEY_DATE to date))
|
||||||
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
"driver-new-route-notification-$routeId",
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,44 @@ fun canCompleteRouteFromDriverApp(
|
|||||||
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 +76,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(),
|
||||||
@@ -158,6 +160,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,
|
||||||
@@ -238,6 +241,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 +377,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) {
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,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 +143,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