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))