diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0bff2bc..134459c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -33,8 +33,8 @@ android {
applicationId = "pl.firmatpp.kierowca"
minSdk = 26
targetSdk = 35
- versionCode = 29
- versionName = "1.0.28"
+ versionCode = 33
+ versionName = "1.0.32"
setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bd4844a..ce5f6fa 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
(null)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
setContent {
TppKierowcaTheme {
val viewModel: DriverViewModel = viewModel()
- DriverApp(viewModel = viewModel)
+ DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId)
}
}
}
+
+ override fun onNewIntent(intent: android.content.Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID)
+ }
+
+ companion object {
+ const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID"
+ }
}
diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt
index 5736e78..c4b5d74 100644
--- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt
+++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt
@@ -15,6 +15,8 @@ import pl.firmatpp.kierowca.data.model.BootstrapResponse
import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.DriverDto
+import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
+import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.OtpResponse
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
@@ -50,6 +52,9 @@ class DriverRepository(
suspend fun completeRoute(routeId: String) =
api.completeRoute(authHeader(requireToken()), routeId)
+ suspend fun updateNotificationPreferences(notifyNewRoutes: Boolean): NotificationPreferencesDto =
+ api.updateNotificationPreferences(authHeader(requireToken()), NotificationPreferencesBody(notifyNewRoutes))
+
suspend fun syncState(date: String?, routeId: String? = null): SyncStateResponse =
api.syncState(authHeader(requireToken()), date, routeId)
diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt
index 89b6617..c1024d5 100644
--- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt
+++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt
@@ -7,6 +7,8 @@ import pl.firmatpp.kierowca.data.model.BroadcastAuthBody
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
import pl.firmatpp.kierowca.data.model.CompleteRouteResponse
import pl.firmatpp.kierowca.data.model.OtpResponse
+import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody
+import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto
import pl.firmatpp.kierowca.data.model.PhotoUploadResponse
import pl.firmatpp.kierowca.data.model.PushTokenBody
import pl.firmatpp.kierowca.data.model.RealtimeStatusBody
@@ -36,6 +38,17 @@ interface MobileDriverApi {
@POST("mobile/driver/auth/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map
+ @GET("mobile/driver/notification-preferences")
+ suspend fun notificationPreferences(
+ @Header("Authorization") authorization: String,
+ ): NotificationPreferencesDto
+
+ @PUT("mobile/driver/notification-preferences")
+ suspend fun updateNotificationPreferences(
+ @Header("Authorization") authorization: String,
+ @Body body: NotificationPreferencesBody,
+ ): NotificationPreferencesDto
+
@GET("mobile/driver/bootstrap")
suspend fun bootstrap(
@Header("Authorization") authorization: String,
diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt
index f474f61..3d996d0 100644
--- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt
+++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt
@@ -46,6 +46,7 @@ data class BootstrapResponse(
val session: DriverSessionDto,
val routes: RoutesBucketDto,
val driverAppSettings: DriverAppSettingsDto?,
+ val notificationPreferences: NotificationPreferencesDto? = null,
val syncState: SyncStateResponse? = null,
)
@@ -66,6 +67,10 @@ data class DriverSessionDto(
val lastSyncAt: String,
)
+data class NotificationPreferencesDto(
+ val notifyNewRoutes: Boolean = false,
+)
+
data class RoutesBucketDto(
val today: List = emptyList(),
)
@@ -110,6 +115,10 @@ data class PushTokenBody(
val platform: String = "android",
)
+data class NotificationPreferencesBody(
+ val notifyNewRoutes: Boolean,
+)
+
data class RealtimeStatusBody(
val reverbStatus: String,
val socketId: String? = null,
@@ -118,6 +127,7 @@ data class RealtimeStatusBody(
data class DriverRouteDto(
val id: String,
+ val routeDate: String? = null,
val startsAt: String,
val originName: String,
val destinationName: String,
diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt
index 3d5bc29..effce86 100644
--- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt
+++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt
@@ -22,6 +22,15 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val data = message.data
+ if (data["type"] == "driver_new_route_notification") {
+ NewRouteNotificationWorker.enqueue(
+ context = applicationContext,
+ routeId = data["routeId"],
+ date = data["date"],
+ )
+ return
+ }
+
if (data["type"] != "driver_sync_hint") return
DriverSyncWorker.enqueue(
diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationFormatter.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationFormatter.kt
new file mode 100644
index 0000000..c7d67c0
--- /dev/null
+++ b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationFormatter.kt
@@ -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ę"
+ }
+}
diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt
new file mode 100644
index 0000000..577129f
--- /dev/null
+++ b/app/src/main/java/pl/firmatpp/kierowca/sync/NewRouteNotificationWorker.kt
@@ -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()
+ .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,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt
index d1d6050..a8bfe5f 100644
--- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt
+++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
@@ -80,6 +81,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -104,12 +106,14 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.verticalScroll
import androidx.core.content.ContextCompat
+import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -138,10 +142,20 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper
import pl.firmatpp.kierowca.ui.theme.TppColors
@Composable
-fun DriverApp(viewModel: DriverViewModel) {
+fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) {
val state by viewModel.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
+ val context = LocalContext.current
var appInForeground by remember { mutableStateOf(true) }
+ val notificationPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission(),
+ ) { granted ->
+ if (granted) {
+ viewModel.updateNewRouteNotifications(true)
+ } else {
+ viewModel.markNotificationPermissionDenied()
+ }
+ }
BackHandler(enabled = state.screen != DriverScreen.Initializing && state.screen != DriverScreen.Phone && state.screen != DriverScreen.Routes) {
viewModel.back()
@@ -173,6 +187,9 @@ fun DriverApp(viewModel: DriverViewModel) {
enabled = state.screen == DriverScreen.Phone || state.screen == DriverScreen.Otp,
onCode = viewModel::updateOtpCode,
)
+ LaunchedEffect(initialRouteId) {
+ viewModel.openRouteFromNotification(initialRouteId)
+ }
Box(Modifier.fillMaxSize().background(TppColors.Surface)) {
when (state.screen) {
@@ -187,7 +204,24 @@ fun DriverApp(viewModel: DriverViewModel) {
onPhotoQueue = viewModel::openPhotoQueue,
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(
state,
viewModel::back,
@@ -217,33 +251,38 @@ fun DriverApp(viewModel: DriverViewModel) {
@Composable
private fun StartupScreen() {
- Column(
+ BoxWithConstraints(
Modifier
.fillMaxSize()
.navigationBarsPadding()
.background(TppColors.Surface),
- horizontalAlignment = Alignment.CenterHorizontally,
) {
- Column(Modifier.fillMaxWidth().statusBarsPadding()) {
- BrandBannerHeader(height = 178.dp)
- }
+ val bannerHeight = authBrandBannerHeightDp(maxHeight.value.toInt()).dp
+ val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
Column(
- Modifier
- .weight(1f)
- .fillMaxWidth()
- .padding(horizontal = 32.dp),
+ Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- )
- {
- CircularProgressIndicator(color = TppColors.Forest)
- Spacer(Modifier.height(20.dp))
- Text(
- "Sprawdzanie sesji",
- color = TppColors.Muted,
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.SemiBold,
- )
+ ) {
+ Column(Modifier.fillMaxWidth().statusBarsPadding()) {
+ BrandBannerHeader(height = bannerHeight)
+ }
+ Column(
+ Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(horizontal = horizontalPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(color = TppColors.Forest)
+ Spacer(Modifier.height(20.dp))
+ Text(
+ "Sprawdzanie sesji",
+ color = TppColors.Muted,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
}
}
}
@@ -251,84 +290,97 @@ private fun StartupScreen() {
@Composable
private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
var phoneDigits by remember(state.phone) { mutableStateOf(polishPhoneDigits(state.phone)) }
- Column(
+ BoxWithConstraints(
Modifier
.fillMaxSize()
.background(TppColors.Surface)
.navigationBarsPadding(),
) {
- Column(Modifier.fillMaxWidth().statusBarsPadding()) {
- BrandBannerHeader(height = 190.dp)
- }
+ 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()
- .verticalScroll(rememberScrollState())
- .padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp),
- horizontalAlignment = Alignment.Start,
+ .fillMaxSize(),
) {
- Text(
- "Firma TPP - Kierowca",
- color = TppColors.Forest,
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.Bold,
- fontSize = 14.sp,
- )
- Spacer(Modifier.height(10.dp))
- Text(
- "Zaloguj się",
- style = MaterialTheme.typography.headlineLarge,
- color = TppColors.Ink,
- fontWeight = FontWeight.Bold,
- )
- Spacer(Modifier.height(8.dp))
- Text(
- "Wpisz numer telefonu kierowcy. Kod OTP wyślemy SMS-em.",
- color = TppColors.Muted,
- fontSize = 17.sp,
- fontWeight = FontWeight.Medium,
- )
- Spacer(Modifier.height(28.dp))
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
- border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
- shape = RoundedCornerShape(8.dp),
+ Column(Modifier.fillMaxWidth().statusBarsPadding()) {
+ BrandBannerHeader(height = bannerHeight)
+ }
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(start = horizontalPadding, top = formTopPadding, end = horizontalPadding, bottom = 28.dp),
+ horizontalAlignment = Alignment.Start,
) {
- Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
- Text(
- "Numer telefonu",
- color = TppColors.Ink,
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.Bold,
- fontSize = 18.sp,
- )
- PhoneNumberField(
- digits = phoneDigits,
- onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
- )
- Text(
- "Dostęp tylko dla upoważnionych kierowców.",
- color = TppColors.Muted,
- fontWeight = FontWeight.SemiBold,
- fontSize = 15.sp,
- )
- ErrorText(state.error)
- Button(
- onClick = { onSubmit("+48$phoneDigits") },
- modifier = Modifier
- .fillMaxWidth()
- .height(64.dp),
- colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
- shape = RoundedCornerShape(4.dp),
- contentPadding = PaddingValues(horizontal = 20.dp),
- ) {
+ Text(
+ "Firma TPP - Kierowca",
+ color = TppColors.Forest,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Bold,
+ fontSize = 14.sp,
+ )
+ Spacer(Modifier.height(10.dp))
+ Text(
+ "Zaloguj się",
+ style = MaterialTheme.typography.headlineLarge,
+ color = TppColors.Ink,
+ fontWeight = FontWeight.Bold,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "Wpisz numer telefonu kierowcy. Kod OTP wyślemy SMS-em.",
+ color = TppColors.Muted,
+ fontSize = 17.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ Spacer(Modifier.height(if (screenHeightDp < 640) 18.dp else 28.dp))
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Color(0xFFFEFFFC)),
+ border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.72f)),
+ shape = RoundedCornerShape(8.dp),
+ ) {
+ Column(Modifier.fillMaxWidth().padding(cardPadding), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text(
- "Wyślij kod OTP",
+ "Numer telefonu",
+ color = TppColors.Ink,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
- fontSize = 20.sp,
+ fontSize = 18.sp,
)
+ PhoneNumberField(
+ digits = phoneDigits,
+ screenWidthDp = screenWidthDp,
+ onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
+ )
+ Text(
+ "Dostęp tylko dla upoważnionych kierowców.",
+ color = TppColors.Muted,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 15.sp,
+ )
+ ErrorText(state.error)
+ Button(
+ onClick = { onSubmit("+48$phoneDigits") },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = TppColors.ContainerGreen),
+ shape = RoundedCornerShape(4.dp),
+ contentPadding = PaddingValues(horizontal = 20.dp),
+ ) {
+ Text(
+ "Wyślij kod OTP",
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ )
+ }
}
}
}
@@ -377,23 +429,29 @@ private fun BrandBannerHeader(height: Dp) {
}
@Composable
-private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
+private fun PhoneNumberField(digits: String, screenWidthDp: Int, onDigitsChange: (String) -> Unit) {
+ val fieldHeight = if (screenWidthDp < 360) 68.dp else if (screenWidthDp < 420) 76.dp else 82.dp
+ val prefixWidth = phonePrefixWidthDp(screenWidthDp).dp
+ val prefixFontSize = if (screenWidthDp < 360) 20.sp else if (screenWidthDp < 420) 23.sp else 26.sp
+ val inputFontSize = if (screenWidthDp < 360) 22.sp else if (screenWidthDp < 420) 25.sp else 28.sp
+ val inputPadding = if (screenWidthDp < 360) 14.dp else if (screenWidthDp < 420) 18.dp else 24.dp
+
Row(
modifier = Modifier
.fillMaxWidth()
- .height(82.dp)
+ .height(fieldHeight)
.background(Color(0xFFFEFFFC), RoundedCornerShape(2.dp))
.border(BorderStroke(1.dp, TppColors.Outline), RoundedCornerShape(2.dp)),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
- .width(94.dp)
+ .width(prefixWidth)
.fillMaxHeight()
.background(TppColors.Panel),
contentAlignment = Alignment.Center,
) {
- Text("+48", color = TppColors.Ink, fontSize = 26.sp)
+ Text("+48", color = TppColors.Ink, fontSize = prefixFontSize)
}
Box(Modifier.width(1.dp).fillMaxHeight().background(TppColors.Outline))
BasicTextField(
@@ -404,19 +462,19 @@ private fun PhoneNumberField(digits: String, onDigitsChange: (String) -> Unit) {
visualTransformation = PolishPhoneNumberVisualTransformation,
textStyle = TextStyle(
color = TppColors.Muted,
- fontSize = 28.sp,
+ fontSize = inputFontSize,
fontWeight = FontWeight.Medium,
),
modifier = Modifier
.weight(1f)
- .padding(horizontal = 24.dp),
+ .padding(horizontal = inputPadding),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (digits.isBlank()) {
Text(
"000 000 000",
color = TppColors.Muted.copy(alpha = 0.92f),
- fontSize = 28.sp,
+ fontSize = inputFontSize,
fontWeight = FontWeight.Medium,
)
}
@@ -451,14 +509,31 @@ private fun OtpScreen(
@Composable
private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit) {
- Column(
- Modifier.fillMaxSize().padding(20.dp),
- verticalArrangement = Arrangement.Center,
+ BoxWithConstraints(
+ Modifier
+ .fillMaxSize()
+ .background(TppColors.Surface)
+ .statusBarsPadding()
+ .navigationBarsPadding(),
) {
- if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") }
- Text(title, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = TppColors.Forest)
- Text(subtitle, color = TppColors.Muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp))
- content()
+ val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = horizontalPadding, vertical = 20.dp),
+ verticalArrangement = Arrangement.Center,
+ ) {
+ if (onBack != null) {
+ IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
+ Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz")
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+ Text(title, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = TppColors.Forest)
+ Text(subtitle, color = TppColors.Muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp))
+ content()
+ }
}
}
@@ -472,63 +547,83 @@ private fun RoutesScreen(
onPhotoQueue: () -> Unit,
onRoute: (String) -> Unit,
) {
- Scaffold(
- topBar = { StitchHeader() },
- bottomBar = {
- StitchBottomBar(
- activeScreen = DriverScreen.Routes,
- onRoutes = onRefresh,
- onProfile = onProfile,
- )
- },
- containerColor = TppColors.Surface,
- ) { padding ->
- val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
- Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
- LazyColumn(
- Modifier.fillMaxSize(),
- contentPadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 20.dp),
- verticalArrangement = Arrangement.spacedBy(18.dp),
- ) {
- item {
- Row(
- Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- routeListTitle(state.selectedDate),
- style = MaterialTheme.typography.headlineLarge,
- fontWeight = FontWeight.Bold,
- color = TppColors.Ink,
- )
- DateChip(state.selectedDate)
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val screenWidthDp = maxWidth.value.toInt()
+ val screenHeightDp = maxHeight.value.toInt()
+ val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
+ val compactWidth = screenWidthDp < 360
+
+ Scaffold(
+ topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) },
+ bottomBar = {
+ StitchBottomBar(
+ activeScreen = DriverScreen.Routes,
+ height = bottomNavigationHeightDp(screenHeightDp).dp,
+ onRoutes = onRefresh,
+ onProfile = onProfile,
+ )
+ },
+ containerColor = TppColors.Surface,
+ ) { padding ->
+ val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh)
+ Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = horizontalPadding, top = 20.dp, end = horizontalPadding, bottom = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(18.dp),
+ ) {
+ item {
+ if (compactWidth) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ routeListTitle(state.selectedDate),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = TppColors.Ink,
+ )
+ DateChip(state.selectedDate)
+ }
+ } else {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ routeListTitle(state.selectedDate),
+ style = MaterialTheme.typography.headlineLarge,
+ fontWeight = FontWeight.Bold,
+ color = TppColors.Ink,
+ )
+ DateChip(state.selectedDate)
+ }
+ }
}
+ item {
+ RouteDateSelector(
+ selectedDate = state.selectedDate,
+ minDate = state.minRouteDate,
+ maxDate = state.maxRouteDate,
+ onDate = onDate,
+ )
+ }
+ item { OfflineStaleBanner(state) }
+ item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
+ if (state.routes.isEmpty()) {
+ item { EmptyState("Brak kursow na wybrany dzien") }
+ } else {
+ items(state.routes, key = { it.id }) { route -> StitchRouteCard(route, onRoute) }
+ }
+ item { ErrorText(state.error) }
}
- item {
- RouteDateSelector(
- selectedDate = state.selectedDate,
- minDate = state.minRouteDate,
- maxDate = state.maxRouteDate,
- onDate = onDate,
- )
- }
- item { OfflineStaleBanner(state) }
- item { PhotoQueueBanner(state.queuedPhotoUploads, onPhotoQueue) }
- if (state.routes.isEmpty()) {
- item { EmptyState("Brak kursow na wybrany dzien") }
- } else {
- items(state.routes, key = { it.id }) { route -> StitchRouteCard(route, onRoute) }
- }
- item { ErrorText(state.error) }
+ PullRefreshIndicator(
+ refreshing = state.refreshing,
+ state = pullRefreshState,
+ modifier = Modifier.align(Alignment.TopCenter),
+ backgroundColor = Color.White,
+ contentColor = TppColors.Forest,
+ )
}
- PullRefreshIndicator(
- refreshing = state.refreshing,
- state = pullRefreshState,
- modifier = Modifier.align(Alignment.TopCenter),
- backgroundColor = Color.White,
- contentColor = TppColors.Forest,
- )
}
}
}
@@ -581,29 +676,33 @@ private fun PhotoQueueScreen(
) {
val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis }
- Scaffold(
- topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") },
- containerColor = TppColors.Surface,
- ) { padding ->
- LazyColumn(
- Modifier.fillMaxSize().padding(padding),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- item { OfflineStaleBanner(state) }
- if (uploads.isEmpty()) {
- item { EmptyState("Brak zdjęć oczekujących na przesłanie") }
- } else {
- item {
- Text(
- "Zdjęcia do przesłania (${uploads.size})",
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = TppColors.Ink,
- )
- }
- items(uploads, key = { it.clientRequestId }) { upload ->
- PhotoQueueItem(upload, onRetryUpload)
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
+
+ Scaffold(
+ topBar = { DetailHeader(onBack, title = "Kolejka zdjęć") },
+ containerColor = TppColors.Surface,
+ ) { padding ->
+ LazyColumn(
+ Modifier.fillMaxSize().padding(padding),
+ contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ item { OfflineStaleBanner(state) }
+ if (uploads.isEmpty()) {
+ item { EmptyState("Brak zdjęć oczekujących na przesłanie") }
+ } else {
+ item {
+ Text(
+ "Zdjęcia do przesłania (${uploads.size})",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = TppColors.Ink,
+ )
+ }
+ items(uploads, key = { it.clientRequestId }) { upload ->
+ PhotoQueueItem(upload, onRetryUpload)
+ }
}
}
}
@@ -721,9 +820,9 @@ private fun PhotoUploadFailureDialog(details: String, onDismiss: () -> Unit) {
}
@Composable
-private fun StitchHeader() {
+private fun StitchHeader(height: Dp = 124.dp) {
Column(Modifier.fillMaxWidth().background(TppColors.Surface).statusBarsPadding()) {
- BrandBannerHeader(height = 124.dp)
+ BrandBannerHeader(height = height)
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.86f), thickness = 1.dp)
}
}
@@ -811,39 +910,56 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, TppColors.Outline),
) {
- Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
- Box(
- Modifier
- .fillMaxHeight()
- .width(8.dp)
- .background(stripColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)),
- )
- Column(Modifier.fillMaxWidth()) {
- Column(Modifier.padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 20.dp)) {
- Row(
- Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- StatusChip(route.status, stripColor)
- Text(
- "Kontrakt: #${display.contractLabel.trimStart('#')}",
- fontFamily = FontFamily.Monospace,
- color = TppColors.Ink,
- fontWeight = FontWeight.SemiBold,
- )
+ BoxWithConstraints(Modifier.fillMaxWidth()) {
+ val compactWidth = maxWidth < 340.dp
+ val contentPadding = if (compactWidth) 16.dp else 24.dp
+
+ Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
+ Box(
+ Modifier
+ .fillMaxHeight()
+ .width(6.dp)
+ .background(stripColor, RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp)),
+ )
+ Column(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(start = contentPadding, top = contentPadding, end = contentPadding, bottom = 20.dp)) {
+ if (compactWidth) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ StatusChip(route.status, stripColor)
+ Text(
+ "Kontrakt: #${display.contractLabel.trimStart('#')}",
+ fontFamily = FontFamily.Monospace,
+ color = TppColors.Ink,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ } else {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ StatusChip(route.status, stripColor)
+ Text(
+ "Kontrakt: #${display.contractLabel.trimStart('#')}",
+ fontFamily = FontFamily.Monospace,
+ color = TppColors.Ink,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ Spacer(Modifier.height(if (compactWidth) 18.dp else 26.dp))
+ RouteTimeline(display.origin, display.destination)
+ }
+ HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.55f))
+ Button(
+ onClick = { onRoute(route.id) },
+ modifier = Modifier.fillMaxWidth().padding(16.dp).height(56.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = stripColor),
+ shape = RoundedCornerShape(4.dp),
+ ) {
+ Text("Szczegóły →", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
- Spacer(Modifier.height(26.dp))
- RouteTimeline(display.origin, display.destination)
- }
- HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.55f))
- Button(
- onClick = { onRoute(route.id) },
- modifier = Modifier.fillMaxWidth().padding(16.dp).height(56.dp),
- colors = ButtonDefaults.buttonColors(containerColor = stripColor),
- shape = RoundedCornerShape(4.dp),
- ) {
- Text("Szczegóły →", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
}
}
}
@@ -865,7 +981,7 @@ private fun StatusChip(status: String, color: Color) {
@Composable
private fun RouteTimeline(origin: String, destination: String) {
- Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(18.dp).background(Color.White, RoundedCornerShape(50)).padding(3.dp)) {
Box(Modifier.fillMaxSize().background(TppColors.Forest, RoundedCornerShape(50)))
@@ -873,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.size(18.dp).background(TppColors.Navy, RoundedCornerShape(50)))
}
- Column(verticalArrangement = Arrangement.spacedBy(30.dp)) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(30.dp)) {
Column {
Text("SKĄD", color = TppColors.Muted, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
Text(origin, style = MaterialTheme.typography.titleLarge, color = TppColors.Ink, fontWeight = FontWeight.Bold)
@@ -892,59 +1008,138 @@ private fun ProfileScreen(
onRoutes: () -> Unit,
onProfile: () -> Unit,
onLogout: () -> Unit,
+ onNewRouteNotificationsChanged: (Boolean) -> Unit,
+ onOpenNotificationSettings: () -> Unit,
) {
- Scaffold(
- topBar = { StitchHeader() },
- bottomBar = {
- StitchBottomBar(
- activeScreen = DriverScreen.Profile,
- onRoutes = onRoutes,
- onProfile = onProfile,
- )
- },
- containerColor = TppColors.Surface,
- ) { padding ->
- Column(
- Modifier
- .fillMaxSize()
- .padding(padding)
- .padding(horizontal = 20.dp, vertical = 24.dp),
- verticalArrangement = Arrangement.spacedBy(18.dp),
- ) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = Color.White),
- shape = RoundedCornerShape(8.dp),
- border = BorderStroke(1.dp, TppColors.Outline),
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val screenWidthDp = maxWidth.value.toInt()
+ val screenHeightDp = maxHeight.value.toInt()
+ val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
+
+ Scaffold(
+ topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) },
+ bottomBar = {
+ StitchBottomBar(
+ activeScreen = DriverScreen.Profile,
+ height = bottomNavigationHeightDp(screenHeightDp).dp,
+ onRoutes = onRoutes,
+ onProfile = onProfile,
+ )
+ },
+ containerColor = TppColors.Surface,
+ ) { padding ->
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = horizontalPadding, vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(18.dp),
) {
- Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
- Icon(Icons.Outlined.Person, contentDescription = null, tint = TppColors.Forest, modifier = Modifier.size(34.dp))
- Text(
- "Witaj ${state.driver?.displayName.orEmpty().ifBlank { "Kierowco" }}",
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = TppColors.Ink,
- )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ shape = RoundedCornerShape(8.dp),
+ border = BorderStroke(1.dp, TppColors.Outline),
+ ) {
+ Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Icon(Icons.Outlined.Person, contentDescription = null, tint = TppColors.Forest, modifier = Modifier.size(34.dp))
+ Text(
+ "Witaj ${state.driver?.displayName.orEmpty().ifBlank { "Kierowco" }}",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = TppColors.Ink,
+ )
+ }
+ }
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = Color.White),
+ shape = RoundedCornerShape(8.dp),
+ border = BorderStroke(1.dp, TppColors.Outline),
+ ) {
+ Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ Text(
+ "Ustawienia",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = TppColors.Ink,
+ )
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ "Otrzymuj powiadomienia gdy spedytor doda nowy kurs",
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = TppColors.Ink,
+ )
+ Text(
+ "Ta opcja może wprowadzać w błąd, ponieważ część kursów może zostać później usunięta.",
+ style = MaterialTheme.typography.bodySmall,
+ color = TppColors.Muted,
+ )
+ }
+ Switch(
+ checked = state.notifyNewRoutes,
+ onCheckedChange = onNewRouteNotificationsChanged,
+ enabled = !state.refreshing && !state.loading,
+ )
+ }
+ if (state.notificationPermissionDenied) {
+ Button(
+ onClick = onOpenNotificationSettings,
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = TppColors.Navy),
+ shape = RoundedCornerShape(4.dp),
+ ) {
+ Text("Przejdź do ustawień", fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ Button(
+ onClick = onLogout,
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
+ shape = RoundedCornerShape(4.dp),
+ ) {
+ Text("Wyloguj", fontWeight = FontWeight.Bold)
}
- }
- Button(
- onClick = onLogout,
- modifier = Modifier.fillMaxWidth().height(56.dp),
- colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest),
- shape = RoundedCornerShape(4.dp),
- ) {
- Text("Wyloguj", fontWeight = FontWeight.Bold)
}
}
}
}
+private fun canPostNotifications(context: Context): Boolean =
+ hasPostNotificationsRuntimePermission(context) &&
+ NotificationManagerCompat.from(context).areNotificationsEnabled()
+
+private fun hasPostNotificationsRuntimePermission(context: Context): Boolean =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+
+private fun openAppNotificationSettings(context: Context) {
+ val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
+ .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ } else {
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ .setData(Uri.parse("package:${context.packageName}"))
+ }
+
+ context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+}
+
@Composable
-private fun StitchBottomBar(activeScreen: DriverScreen, onRoutes: () -> Unit, onProfile: () -> Unit) {
+private fun StitchBottomBar(activeScreen: DriverScreen, height: Dp = 82.dp, onRoutes: () -> Unit, onProfile: () -> Unit) {
Column(Modifier.fillMaxWidth().background(Color.White).navigationBarsPadding()) {
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.9f), thickness = 2.dp)
Row(
- Modifier.fillMaxWidth().height(82.dp),
+ Modifier.fillMaxWidth().height(height),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -1027,71 +1222,75 @@ private fun DetailScreen(
val route = state.selectedRoute
val canManagePhotos = canManageRoutePhotos(state.selectedDate)
- Scaffold(
- topBar = {
- DetailHeader(onBack)
- },
- containerColor = TppColors.Surface,
- ) { padding ->
- if (route == null) return@Scaffold
- Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
- LazyColumn(
- Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(24.dp),
- ) {
- item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
- item { OfflineStaleBanner(state) }
- item {
- RouteCompletionSection(
- route = route,
- selectedDate = state.selectedDate,
- allowRouteCompletion = state.allowRouteCompletion,
- isOnline = state.isOnline,
- completing = state.completingRoute,
- feedback = state.feedback,
- onComplete = { showCompleteDialog = true },
- )
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
+
+ Scaffold(
+ topBar = {
+ DetailHeader(onBack)
+ },
+ containerColor = TppColors.Surface,
+ ) { padding ->
+ if (route == null) return@Scaffold
+ Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
+ LazyColumn(
+ Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
+ item { OfflineStaleBanner(state) }
+ item {
+ RouteCompletionSection(
+ route = route,
+ selectedDate = state.selectedDate,
+ allowRouteCompletion = state.allowRouteCompletion,
+ isOnline = state.isOnline,
+ completing = state.completingRoute,
+ feedback = state.feedback,
+ onComplete = { showCompleteDialog = true },
+ )
+ }
+ item {
+ CargoDocumentationSection(
+ photos = route.photos,
+ uploads = state.photoUploads,
+ deletingPhotoIds = state.deletingPhotoIds,
+ imageAuthHeader = state.imageAuthHeader,
+ allowGalleryUploads = state.allowGalleryUploads,
+ canManagePhotos = canManagePhotos,
+ onPhoto = onPhoto,
+ onDeletePhoto = onDeletePhoto,
+ onDeleteUpload = onDeleteUpload,
+ onRetryUpload = onRetryUpload,
+ onCamera = {
+ val newUri = createCameraUri(context)
+ cameraUri = newUri
+ val missingPermissions = cameraCapturePermissions(
+ context = context,
+ requirePreciseLocation = state.requirePreciseLocationForPhotos,
+ )
+ if (missingPermissions.isEmpty()) {
+ cameraLauncher.launch(newUri)
+ } else {
+ cameraPermissionLauncher.launch(missingPermissions)
+ }
+ },
+ onGallery = {
+ pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
+ },
+ )
+ }
+ item { ErrorText(state.error) }
}
- item {
- CargoDocumentationSection(
- photos = route.photos,
- uploads = state.photoUploads,
- deletingPhotoIds = state.deletingPhotoIds,
- imageAuthHeader = state.imageAuthHeader,
- allowGalleryUploads = state.allowGalleryUploads,
- canManagePhotos = canManagePhotos,
- onPhoto = onPhoto,
- onDeletePhoto = onDeletePhoto,
- onDeleteUpload = onDeleteUpload,
- onRetryUpload = onRetryUpload,
- onCamera = {
- val newUri = createCameraUri(context)
- cameraUri = newUri
- val missingPermissions = cameraCapturePermissions(
- context = context,
- requirePreciseLocation = state.requirePreciseLocationForPhotos,
- )
- if (missingPermissions.isEmpty()) {
- cameraLauncher.launch(newUri)
- } else {
- cameraPermissionLauncher.launch(missingPermissions)
- }
- },
- onGallery = {
- pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
- },
- )
- }
- item { ErrorText(state.error) }
+ PullRefreshIndicator(
+ refreshing = state.refreshing,
+ state = pullRefreshState,
+ modifier = Modifier.align(Alignment.TopCenter),
+ backgroundColor = Color.White,
+ contentColor = TppColors.Forest,
+ )
}
- PullRefreshIndicator(
- refreshing = state.refreshing,
- state = pullRefreshState,
- modifier = Modifier.align(Alignment.TopCenter),
- backgroundColor = Color.White,
- contentColor = TppColors.Forest,
- )
}
}
@@ -1166,7 +1365,9 @@ private fun DetailHeader(onBack: () -> Unit, title: String = "Szczegóły Trasy"
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = TppColors.Ink,
- modifier = Modifier.padding(start = 4.dp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f).padding(start = 4.dp),
)
}
HorizontalDivider(color = TppColors.Outline, thickness = 1.dp)
@@ -1208,47 +1409,41 @@ private fun RouteCompletionSection(
if (!canCompleteRouteFromDriverApp(route, selectedDate)) return
- Card(
- colors = CardDefaults.cardColors(containerColor = Color.White),
- shape = RoundedCornerShape(8.dp),
- border = BorderStroke(1.dp, TppColors.Outline),
- modifier = Modifier.fillMaxWidth(),
- ) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = onComplete,
- enabled = isOnline && !completing,
- modifier = Modifier.fillMaxWidth().height(58.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = TppColors.Forest,
- disabledContainerColor = TppColors.Outline,
- ),
- shape = RoundedCornerShape(8.dp),
- ) {
- if (completing) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- color = Color.White,
- strokeWidth = 2.dp,
- )
- } else {
- Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White)
- }
- Spacer(Modifier.width(10.dp))
- Text(
- if (completing) "Kończę kurs..." else "Zakończ kurs",
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(
+ onClick = onComplete,
+ enabled = isOnline && !completing,
+ modifier = Modifier.fillMaxWidth().height(64.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = TppColors.Forest,
+ disabledContainerColor = TppColors.Outline,
+ ),
+ shape = RoundedCornerShape(8.dp),
+ ) {
+ if (completing) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
color = Color.White,
- fontWeight = FontWeight.Bold,
- )
- }
- if (!isOnline) {
- Text(
- "Kurs można zakończyć tylko po połączeniu z serwerem.",
- color = TppColors.Muted,
- fontFamily = FontFamily.Monospace,
- style = MaterialTheme.typography.labelMedium,
+ strokeWidth = 2.dp,
)
+ } else {
+ Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color.White)
}
+ Spacer(Modifier.width(10.dp))
+ Text(
+ if (completing) "Kończę kurs..." else "Zakończ kurs",
+ color = Color.White,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ if (!isOnline) {
+ Text(
+ "Kurs można zakończyć tylko po połączeniu z serwerem.",
+ color = TppColors.Muted,
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(horizontal = 4.dp),
+ )
}
}
}
@@ -1328,14 +1523,14 @@ private fun RoutePointBlock(
) {
Row(
Modifier.fillMaxWidth().padding(20.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp),
- verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalAlignment = Alignment.Top,
) {
Box(
- Modifier.size(48.dp).background(iconBackground, RoundedCornerShape(24.dp)),
+ Modifier.size(40.dp).background(iconBackground, RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center,
) {
- Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(26.dp))
+ Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(22.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
@@ -1360,9 +1555,9 @@ private fun RoutePointBlock(
if (navigationPoint?.hasCoordinates() == true) {
IconButton(
onClick = { onNavigate(navigationPoint) },
- modifier = Modifier.size(48.dp).background(TppColors.Forest, RoundedCornerShape(24.dp)),
+ modifier = Modifier.size(42.dp).background(TppColors.Forest, RoundedCornerShape(21.dp)),
) {
- Icon(Icons.Outlined.Navigation, contentDescription = "Nawiguj", tint = Color.White)
+ Icon(Icons.Outlined.Navigation, contentDescription = "Nawiguj", tint = Color.White, modifier = Modifier.size(23.dp))
}
}
}
@@ -1370,14 +1565,25 @@ private fun RoutePointBlock(
@Composable
private fun RouteFactsFooter(route: DriverRouteDto) {
- Column(Modifier.fillMaxWidth().background(TppColors.Panel).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
- Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
- RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.weight(1f))
- RouteFact("Pojazd", route.truckLabel(), Modifier.weight(1f))
- }
- Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
- RouteFact("Nr kontraktu", route.contractCode ?: "#${route.id}", Modifier.weight(1f))
- RouteFact("Dystans", route.distanceLabel(), Modifier.weight(1f))
+ BoxWithConstraints(Modifier.fillMaxWidth().background(TppColors.Panel)) {
+ val columns = routeFactColumns(maxWidth.value.toInt())
+
+ Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ if (columns == 1) {
+ RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.fillMaxWidth())
+ RouteFact("Pojazd", route.truckLabel(), Modifier.fillMaxWidth())
+ RouteFact("Nr kontraktu", route.contractCode ?: "#${route.id}", Modifier.fillMaxWidth())
+ RouteFact("Dystans", route.distanceLabel(), Modifier.fillMaxWidth())
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
+ RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.weight(1f))
+ RouteFact("Pojazd", route.truckLabel(), Modifier.weight(1f))
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
+ RouteFact("Nr kontraktu", route.contractCode ?: "#${route.id}", Modifier.weight(1f))
+ RouteFact("Dystans", route.distanceLabel(), Modifier.weight(1f))
+ }
+ }
}
}
}
@@ -1462,43 +1668,49 @@ private fun PhotoGrid(
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, TppColors.Outline),
) {
- Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
- Text(
- "Załączone zdjęcia ($visibleAttachmentCount)",
- color = TppColors.Muted,
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.SemiBold,
- )
- if (items.isEmpty()) {
- EmptyPhotoState()
- } else {
- Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
- repeat(inlinePhotoGridRows(items.size)) { rowIndex ->
- val startIndex = rowIndex * 2
- val rowItems = items.subList(startIndex, minOf(startIndex + 2, items.size))
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
- rowItems.forEach { item ->
- when (item) {
- is PhotoGridItem.Server -> PhotoTile(
- photo = item.photo,
- imageAuthHeader = imageAuthHeader,
- isDeleting = item.photo.id in deletingPhotoIds,
- onPhoto = onPhoto,
- onDeletePhoto = { pendingDelete = PhotoDeleteTarget.Server(it) },
- modifier = Modifier.weight(1f),
- )
- is PhotoGridItem.Upload -> PendingPhotoTile(
- upload = item.upload,
- isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
- onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
- onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
- onRetry = onRetryUpload,
- modifier = Modifier.weight(1f),
- )
+ BoxWithConstraints(Modifier.fillMaxWidth()) {
+ val compactWidth = maxWidth < 340.dp
+ val contentPadding = if (compactWidth) 12.dp else 20.dp
+ val gridGap = if (compactWidth) 8.dp else 12.dp
+
+ Column(Modifier.padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text(
+ "Załączone zdjęcia ($visibleAttachmentCount)",
+ color = TppColors.Muted,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (items.isEmpty()) {
+ EmptyPhotoState()
+ } else {
+ Column(verticalArrangement = Arrangement.spacedBy(gridGap)) {
+ repeat(inlinePhotoGridRows(items.size)) { rowIndex ->
+ val startIndex = rowIndex * 2
+ val rowItems = items.subList(startIndex, minOf(startIndex + 2, items.size))
+ Row(horizontalArrangement = Arrangement.spacedBy(gridGap), modifier = Modifier.fillMaxWidth()) {
+ rowItems.forEach { item ->
+ when (item) {
+ is PhotoGridItem.Server -> PhotoTile(
+ photo = item.photo,
+ imageAuthHeader = imageAuthHeader,
+ isDeleting = item.photo.id in deletingPhotoIds,
+ onPhoto = onPhoto,
+ onDeletePhoto = { pendingDelete = PhotoDeleteTarget.Server(it) },
+ modifier = Modifier.weight(1f),
+ )
+ is PhotoGridItem.Upload -> PendingPhotoTile(
+ upload = item.upload,
+ isDeleting = item.upload.serverPhotoId?.let { it in deletingPhotoIds } == true,
+ onPhoto = { upload -> localUploadPreviewPhoto(upload)?.let(onPhoto) },
+ onDelete = { pendingDelete = PhotoDeleteTarget.Upload(it) },
+ onRetry = onRetryUpload,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ if (rowItems.size == 1) {
+ Spacer(Modifier.weight(1f))
}
- }
- if (rowItems.size == 1) {
- Spacer(Modifier.weight(1f))
}
}
}
diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt
index 8fd23bf..27ae87f 100644
--- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt
+++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt
@@ -21,6 +21,44 @@ fun canCompleteRouteFromDriverApp(
fun inlinePhotoGridRows(photoCount: Int): Int =
if (photoCount <= 0) 0 else (photoCount + 1) / 2
+fun authBrandBannerHeightDp(screenHeightDp: Int): Int =
+ when {
+ screenHeightDp < 640 -> 118
+ screenHeightDp < 740 -> 150
+ else -> 190
+ }
+
+fun appBrandHeaderHeightDp(screenHeightDp: Int): Int =
+ when {
+ screenHeightDp < 640 -> 92
+ screenHeightDp < 740 -> 108
+ else -> 124
+ }
+
+fun bottomNavigationHeightDp(screenHeightDp: Int): Int =
+ when {
+ screenHeightDp < 640 -> 72
+ screenHeightDp < 740 -> 78
+ else -> 82
+ }
+
+fun screenHorizontalPaddingDp(screenWidthDp: Int): Int =
+ when {
+ screenWidthDp < 360 -> 14
+ screenWidthDp < 420 -> 16
+ else -> 20
+ }
+
+fun phonePrefixWidthDp(screenWidthDp: Int): Int =
+ when {
+ screenWidthDp < 360 -> 70
+ screenWidthDp < 420 -> 82
+ else -> 94
+ }
+
+fun routeFactColumns(screenWidthDp: Int): Int =
+ if (screenWidthDp < 360) 1 else 2
+
fun visiblePhotoAttachmentCount(serverPhotoCount: Int, localUploadCount: Int): Int =
serverPhotoCount.coerceAtLeast(0) + localUploadCount.coerceAtLeast(0)
diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt
index 2db4205..9eca854 100644
--- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt
+++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt
@@ -44,6 +44,8 @@ data class DriverUiState(
val allowGalleryUploads: Boolean = true,
val allowRouteCompletion: Boolean = false,
val requirePreciseLocationForPhotos: Boolean = false,
+ val notifyNewRoutes: Boolean = false,
+ val notificationPermissionDenied: Boolean = false,
val selectedRoute: DriverRouteDto? = null,
val selectedPhoto: RoutePhotoDto? = null,
val photoUploads: List = emptyList(),
@@ -158,6 +160,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion,
requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos
?: it.requirePreciseLocationForPhotos,
+ notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes,
imageAuthHeader = repository.imageAuthHeader(),
isStale = cached.stale || !it.isOnline,
lastSuccessfulSyncAtEpochMillis = cached.syncedAtEpochMillis ?: it.lastSuccessfulSyncAtEpochMillis,
@@ -238,6 +241,36 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Profile, error = null) }
}
+ fun updateNewRouteNotifications(enabled: Boolean) {
+ viewModelScope.launch {
+ _state.update { it.copy(refreshing = true, error = null, notificationPermissionDenied = false) }
+ runCatching { repository.updateNotificationPreferences(enabled) }
+ .onSuccess { preferences ->
+ _state.update {
+ it.copy(
+ notifyNewRoutes = preferences.notifyNewRoutes,
+ feedback = if (preferences.notifyNewRoutes) "Powiadomienia o nowych kursach są włączone." else "Powiadomienia o nowych kursach są wyłączone.",
+ error = null,
+ )
+ }
+ }
+ .onFailure { throwable ->
+ _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) }
+ }
+ _state.update { it.copy(refreshing = false) }
+ }
+ }
+
+ fun markNotificationPermissionDenied() {
+ _state.update {
+ it.copy(
+ notifyNewRoutes = false,
+ notificationPermissionDenied = true,
+ error = "Aby otrzymywać powiadomienia o nowych kursach, włącz powiadomienia w ustawieniach aplikacji.",
+ )
+ }
+ }
+
fun openPhotoQueue() {
_state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) }
}
@@ -344,6 +377,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
_state.update { it.copy(screen = DriverScreen.Photo, selectedPhoto = photo) }
}
+ fun openRouteFromNotification(routeId: String?) {
+ if (routeId.isNullOrBlank()) return
+ openRoute(routeId)
+ }
+
fun back() {
_state.update {
when (it.screen) {
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 2905bae..7b7f580 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,4 +1,10 @@
-
-
-
+
+
+
diff --git a/app/src/test/java/pl/firmatpp/kierowca/sync/NewRouteNotificationFormatterTest.kt b/app/src/test/java/pl/firmatpp/kierowca/sync/NewRouteNotificationFormatterTest.kt
new file mode 100644
index 0000000..95aa01c
--- /dev/null
+++ b/app/src/test/java/pl/firmatpp/kierowca/sync/NewRouteNotificationFormatterTest.kt
@@ -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"))
+ }
+}
diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt
index 92c2533..16318e0 100644
--- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt
+++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt
@@ -43,6 +43,38 @@ class DriverUiRulesTest {
assertEquals(3, inlinePhotoGridRows(5))
}
+ @Test
+ fun usesShorterChromeOnCompactScreens() {
+ assertEquals(118, authBrandBannerHeightDp(600))
+ assertEquals(150, authBrandBannerHeightDp(690))
+ assertEquals(190, authBrandBannerHeightDp(780))
+
+ assertEquals(92, appBrandHeaderHeightDp(600))
+ assertEquals(108, appBrandHeaderHeightDp(690))
+ assertEquals(124, appBrandHeaderHeightDp(780))
+
+ assertEquals(72, bottomNavigationHeightDp(600))
+ assertEquals(78, bottomNavigationHeightDp(690))
+ assertEquals(82, bottomNavigationHeightDp(780))
+ }
+
+ @Test
+ fun reducesHorizontalSpaceOnNarrowScreens() {
+ assertEquals(14, screenHorizontalPaddingDp(340))
+ assertEquals(16, screenHorizontalPaddingDp(390))
+ assertEquals(20, screenHorizontalPaddingDp(440))
+
+ assertEquals(70, phonePrefixWidthDp(340))
+ assertEquals(82, phonePrefixWidthDp(390))
+ assertEquals(94, phonePrefixWidthDp(440))
+ }
+
+ @Test
+ fun stacksDenseRouteFactsOnNarrowScreens() {
+ assertEquals(1, routeFactColumns(340))
+ assertEquals(2, routeFactColumns(390))
+ }
+
@Test
fun countsServerPhotosAndLocalUploadsAsVisibleAttachments() {
assertEquals(0, visiblePhotoAttachmentCount(serverPhotoCount = 0, localUploadCount = 0))