Compare commits

...
5 Commits
23 changed files with 1329 additions and 451 deletions
+2 -2
View File
@@ -33,8 +33,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 28 versionCode = 34
versionName = "1.0.27" versionName = "1.0.33"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+1
View File
@@ -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,
@@ -28,14 +29,21 @@ 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 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 +54,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 +72,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 +92,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 +152,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(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,
)
}
}
}
@@ -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) {
@@ -186,7 +204,24 @@ fun DriverApp(viewModel: DriverViewModel) {
onPhotoQueue = viewModel::openPhotoQueue, onPhotoQueue = viewModel::openPhotoQueue,
onRoute = viewModel::openRoute, onRoute = viewModel::openRoute,
) )
DriverScreen.Profile -> ProfileScreen(state, viewModel::refreshRoutes, viewModel::openProfile, viewModel::logout) 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.Detail -> DetailScreen( DriverScreen.Detail -> DetailScreen(
state, state,
viewModel::back, viewModel::back,
@@ -216,25 +251,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(
@@ -246,24 +285,36 @@ 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 +338,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 +355,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(
@@ -334,6 +386,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
} }
} }
} }
}
@Composable @Composable
private fun BrandBannerHeader(height: Dp) { private fun BrandBannerHeader(height: Dp) {
@@ -376,23 +429,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 +462,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,16 +509,33 @@ 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)
@Composable @Composable
@@ -471,11 +547,18 @@ private fun RoutesScreen(
onPhotoQueue: () -> Unit, onPhotoQueue: () -> Unit,
onRoute: (String) -> Unit, onRoute: (String) -> Unit,
) { ) {
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 = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).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 +569,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 +598,7 @@ private fun RoutesScreen(
DateChip(state.selectedDate) DateChip(state.selectedDate)
} }
} }
}
item { item {
RouteDateSelector( RouteDateSelector(
selectedDate = state.selectedDate, selectedDate = state.selectedDate,
@@ -531,6 +626,7 @@ private fun RoutesScreen(
} }
} }
} }
}
@Composable @Composable
private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: () -> Unit) { private fun PhotoQueueBanner(uploads: List<PhotoUploadEntity>, onPhotoQueue: () -> Unit) {
@@ -580,13 +676,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) }
@@ -608,10 +707,13 @@ 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 +734,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 +748,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) },
@@ -666,11 +786,43 @@ 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 +910,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 +947,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))
@@ -796,6 +964,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
} }
} }
} }
}
@Composable @Composable
private fun StatusChip(status: String, color: Color) { private fun StatusChip(status: String, color: Color) {
@@ -812,7 +981,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 +989,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 +1008,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 +1032,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 +1052,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),
@@ -885,13 +1112,34 @@ 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 +1222,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,7 +1235,7 @@ 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, onNavigate = { openNavigation(context, it) }) }
@@ -1041,6 +1292,7 @@ private fun DetailScreen(
) )
} }
} }
}
if (showCompleteDialog && route != null) { if (showCompleteDialog && route != null) {
AlertDialog( AlertDialog(
@@ -1113,7 +1365,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 +1409,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,11 +1442,11 @@ 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 =
when { when {
@@ -1275,14 +1523,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 +1555,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 +1565,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))
@@ -1328,6 +1585,8 @@ private fun RouteFactsFooter(route: DriverRouteDto) {
} }
} }
} }
}
}
@Composable @Composable
private fun RouteFact(label: String, value: String, modifier: Modifier = Modifier) { private fun RouteFact(label: String, value: String, modifier: Modifier = Modifier) {
@@ -1409,7 +1668,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 +1683,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 +1717,7 @@ private fun PhotoGrid(
} }
} }
} }
}
pendingDelete?.let { target -> pendingDelete?.let { target ->
AlertDialog( AlertDialog(
@@ -1551,6 +1816,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 +1859,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 +1902,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,
@@ -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,
@@ -165,7 +168,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 +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"))