Add new route push notifications
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package pl.firmatpp.kierowca
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -9,13 +12,26 @@ import pl.firmatpp.kierowca.ui.DriverViewModel
|
||||
import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var notificationRouteId by mutableStateOf<String?>(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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<String, Boolean>
|
||||
|
||||
@GET("mobile/driver/notification-preferences")
|
||||
suspend fun notificationPreferences(
|
||||
@Header("Authorization") authorization: String,
|
||||
): NotificationPreferencesDto
|
||||
|
||||
@PUT("mobile/driver/notification-preferences")
|
||||
suspend fun updateNotificationPreferences(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Body body: NotificationPreferencesBody,
|
||||
): NotificationPreferencesDto
|
||||
|
||||
@GET("mobile/driver/bootstrap")
|
||||
suspend fun bootstrap(
|
||||
@Header("Authorization") authorization: String,
|
||||
|
||||
@@ -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<DriverRouteDto> = 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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package pl.firmatpp.kierowca.sync
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.util.Locale
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.DateTimeFormatter
|
||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||
|
||||
object NewRouteNotificationFormatter {
|
||||
private val polishLocale = Locale("pl", "PL")
|
||||
private val monthFormatter = DateTimeFormatter.ofPattern("d MMMM", polishLocale)
|
||||
|
||||
fun title(routeDate: String?, today: LocalDate = LocalDate.now()): String {
|
||||
val label = humanDate(routeDate, today) ?: return "Nowy kurs"
|
||||
|
||||
return "Nowy kurs: $label"
|
||||
}
|
||||
|
||||
fun collapsedText(route: DriverRouteDto): String =
|
||||
listOf(route.originName, route.destinationName)
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(" → ")
|
||||
.ifBlank { route.relationLabel }
|
||||
.let { relation ->
|
||||
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode
|
||||
if (contract.isNullOrBlank()) relation else "$relation · $contract"
|
||||
}
|
||||
|
||||
fun expandedText(route: DriverRouteDto): String {
|
||||
val contract = route.contractName?.takeIf(String::isNotBlank) ?: route.contractCode ?: "Brak nazwy kontraktu"
|
||||
|
||||
return listOf(
|
||||
"Skąd: ${route.originName.ifBlank { "Nie podano" }}",
|
||||
"Dokąd: ${route.destinationName.ifBlank { "Nie podano" }}",
|
||||
"Kontrakt: $contract",
|
||||
).joinToString("\n")
|
||||
}
|
||||
|
||||
private fun humanDate(routeDate: String?, today: LocalDate): String? {
|
||||
val date = runCatching { routeDate?.let(LocalDate::parse) }.getOrNull() ?: return null
|
||||
|
||||
return when {
|
||||
date == today -> "dzisiaj"
|
||||
date == today.plusDays(1) -> "jutro"
|
||||
date.isAfter(today.plusDays(1)) && !date.isAfter(today.plusDays(7)) ->
|
||||
"w ${weekdayAccusative(date.dayOfWeek)}"
|
||||
else -> date.format(monthFormatter).lowercase(polishLocale)
|
||||
}
|
||||
}
|
||||
|
||||
private fun weekdayAccusative(dayOfWeek: DayOfWeek): String =
|
||||
when (dayOfWeek) {
|
||||
DayOfWeek.MONDAY -> "poniedziałek"
|
||||
DayOfWeek.TUESDAY -> "wtorek"
|
||||
DayOfWeek.WEDNESDAY -> "środę"
|
||||
DayOfWeek.THURSDAY -> "czwartek"
|
||||
DayOfWeek.FRIDAY -> "piątek"
|
||||
DayOfWeek.SATURDAY -> "sobotę"
|
||||
DayOfWeek.SUNDAY -> "niedzielę"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package pl.firmatpp.kierowca.sync
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import java.util.concurrent.TimeUnit
|
||||
import pl.firmatpp.kierowca.MainActivity
|
||||
import pl.firmatpp.kierowca.R
|
||||
import pl.firmatpp.kierowca.data.ApiErrorKind
|
||||
import pl.firmatpp.kierowca.data.ApiErrorMapper
|
||||
import pl.firmatpp.kierowca.data.DriverRepository
|
||||
import retrofit2.HttpException
|
||||
|
||||
class NewRouteNotificationWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
private val repository = DriverRepository(appContext)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val routeId = inputData.getString(KEY_ROUTE_ID)?.takeIf(String::isNotBlank) ?: return Result.success()
|
||||
|
||||
return runCatching {
|
||||
val route = repository.route(routeId).route
|
||||
if (!canShowNotifications(applicationContext)) {
|
||||
return@runCatching Result.success()
|
||||
}
|
||||
|
||||
createChannel(applicationContext)
|
||||
val contentTitle = NewRouteNotificationFormatter.title(route.routeDate ?: inputData.getString(KEY_DATE))
|
||||
val contentText = NewRouteNotificationFormatter.collapsedText(route)
|
||||
val expandedText = NewRouteNotificationFormatter.expandedText(route)
|
||||
val intent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_ROUTE_ID, route.id)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
route.id.hashCode(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(contentTitle)
|
||||
.setContentText(contentText)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expandedText))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(applicationContext).notify(route.id.hashCode(), notification)
|
||||
Result.success()
|
||||
}.getOrElse { throwable ->
|
||||
if (throwable is HttpException && throwable.code() == 404) {
|
||||
Result.success()
|
||||
} else {
|
||||
val error = ApiErrorMapper.map(throwable)
|
||||
if (error.retryable && error.kind != ApiErrorKind.Auth) Result.retry() else Result.success()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Nowe kursy",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Powiadomienia o nowych kursach dodanych przez spedytora."
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun canShowNotifications(context: Context): Boolean {
|
||||
val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "new_routes"
|
||||
private const val KEY_ROUTE_ID = "routeId"
|
||||
private const val KEY_DATE = "date"
|
||||
|
||||
fun enqueue(context: Context, routeId: String?, date: String?) {
|
||||
if (routeId.isNullOrBlank()) return
|
||||
|
||||
val request = OneTimeWorkRequestBuilder<NewRouteNotificationWorker>()
|
||||
.setInputData(workDataOf(KEY_ROUTE_ID to routeId, KEY_DATE to date))
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
"driver-new-route-notification-$routeId",
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.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,25 +251,29 @@ fun DriverApp(viewModel: DriverViewModel) {
|
||||
|
||||
@Composable
|
||||
private fun StartupScreen() {
|
||||
Column(
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.background(TppColors.Surface),
|
||||
) {
|
||||
val bannerHeight = authBrandBannerHeightDp(maxHeight.value.toInt()).dp
|
||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 178.dp)
|
||||
BrandBannerHeader(height = bannerHeight)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp),
|
||||
.padding(horizontal = horizontalPadding),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
)
|
||||
{
|
||||
) {
|
||||
CircularProgressIndicator(color = TppColors.Forest)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
@@ -246,25 +284,37 @@ 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(),
|
||||
) {
|
||||
val screenWidthDp = maxWidth.value.toInt()
|
||||
val screenHeightDp = maxHeight.value.toInt()
|
||||
val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
|
||||
val bannerHeight = authBrandBannerHeightDp(screenHeightDp).dp
|
||||
val formTopPadding = if (screenHeightDp < 640) 18.dp else 28.dp
|
||||
val cardPadding = if (screenWidthDp < 360) 16.dp else 20.dp
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().statusBarsPadding()) {
|
||||
BrandBannerHeader(height = 190.dp)
|
||||
BrandBannerHeader(height = bannerHeight)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(start = 24.dp, top = 28.dp, end = 24.dp, bottom = 28.dp),
|
||||
.padding(start = horizontalPadding, top = formTopPadding, end = horizontalPadding, bottom = 28.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
@@ -288,14 +338,14 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
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(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(cardPadding), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||
Text(
|
||||
"Numer telefonu",
|
||||
color = TppColors.Ink,
|
||||
@@ -305,6 +355,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
)
|
||||
PhoneNumberField(
|
||||
digits = phoneDigits,
|
||||
screenWidthDp = screenWidthDp,
|
||||
onDigitsChange = { phoneDigits = polishPhoneDigits(it) },
|
||||
)
|
||||
Text(
|
||||
@@ -334,6 +385,7 @@ private fun PhoneScreen(state: DriverUiState, onSubmit: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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,15 +509,32 @@ private fun OtpScreen(
|
||||
|
||||
@Composable
|
||||
private fun AuthShell(title: String, subtitle: String, onBack: (() -> Unit)? = null, content: @Composable ColumnScope.() -> Unit) {
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(TppColors.Surface)
|
||||
.statusBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(20.dp),
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = horizontalPadding, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz") }
|
||||
if (onBack != null) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
|
||||
Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz")
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
Text(title, style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = TppColors.Forest)
|
||||
Text(subtitle, color = TppColors.Muted, modifier = Modifier.padding(top = 8.dp, bottom = 24.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
|
||||
@@ -472,11 +547,18 @@ private fun RoutesScreen(
|
||||
onPhotoQueue: () -> Unit,
|
||||
onRoute: (String) -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val screenWidthDp = maxWidth.value.toInt()
|
||||
val screenHeightDp = maxHeight.value.toInt()
|
||||
val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
|
||||
val compactWidth = screenWidthDp < 360
|
||||
|
||||
Scaffold(
|
||||
topBar = { StitchHeader() },
|
||||
topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) },
|
||||
bottomBar = {
|
||||
StitchBottomBar(
|
||||
activeScreen = DriverScreen.Routes,
|
||||
height = bottomNavigationHeightDp(screenHeightDp).dp,
|
||||
onRoutes = onRefresh,
|
||||
onProfile = onProfile,
|
||||
)
|
||||
@@ -487,10 +569,21 @@ private fun RoutesScreen(
|
||||
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 20.dp),
|
||||
contentPadding = PaddingValues(start = horizontalPadding, top = 20.dp, end = horizontalPadding, bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
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,
|
||||
@@ -505,6 +598,7 @@ private fun RoutesScreen(
|
||||
DateChip(state.selectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
RouteDateSelector(
|
||||
selectedDate = state.selectedDate,
|
||||
@@ -531,6 +625,7 @@ private fun RoutesScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -581,13 +676,16 @@ private fun PhotoQueueScreen(
|
||||
) {
|
||||
val uploads = state.queuedPhotoUploads.sortedBy { it.createdAtEpochMillis }
|
||||
|
||||
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(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item { OfflineStaleBanner(state) }
|
||||
@@ -608,6 +706,7 @@ private fun PhotoQueueScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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,15 +910,30 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val compactWidth = maxWidth < 340.dp
|
||||
val contentPadding = if (compactWidth) 16.dp else 24.dp
|
||||
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.width(8.dp)
|
||||
.width(6.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)) {
|
||||
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,
|
||||
@@ -833,7 +947,8 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(26.dp))
|
||||
}
|
||||
Spacer(Modifier.height(if (compactWidth) 18.dp else 26.dp))
|
||||
RouteTimeline(display.origin, display.destination)
|
||||
}
|
||||
HorizontalDivider(color = TppColors.Outline.copy(alpha = 0.55f))
|
||||
@@ -848,6 +963,7 @@ private fun StitchRouteCard(route: DriverRouteDto, onRoute: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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,12 +1008,20 @@ private fun ProfileScreen(
|
||||
onRoutes: () -> Unit,
|
||||
onProfile: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNewRouteNotificationsChanged: (Boolean) -> Unit,
|
||||
onOpenNotificationSettings: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val screenWidthDp = maxWidth.value.toInt()
|
||||
val screenHeightDp = maxHeight.value.toInt()
|
||||
val horizontalPadding = screenHorizontalPaddingDp(screenWidthDp).dp
|
||||
|
||||
Scaffold(
|
||||
topBar = { StitchHeader() },
|
||||
topBar = { StitchHeader(height = appBrandHeaderHeightDp(screenHeightDp).dp) },
|
||||
bottomBar = {
|
||||
StitchBottomBar(
|
||||
activeScreen = DriverScreen.Profile,
|
||||
height = bottomNavigationHeightDp(screenHeightDp).dp,
|
||||
onRoutes = onRoutes,
|
||||
onProfile = onProfile,
|
||||
)
|
||||
@@ -908,7 +1032,8 @@ private fun ProfileScreen(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = horizontalPadding, vertical = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Card(
|
||||
@@ -927,6 +1052,55 @@ private fun ProfileScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(
|
||||
"Ustawienia",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TppColors.Ink,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
"Otrzymuj powiadomienia gdy spedytor doda nowy kurs",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TppColors.Ink,
|
||||
)
|
||||
Text(
|
||||
"Ta opcja może wprowadzać w błąd, ponieważ część kursów może zostać później usunięta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = TppColors.Muted,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.notifyNewRoutes,
|
||||
onCheckedChange = onNewRouteNotificationsChanged,
|
||||
enabled = !state.refreshing && !state.loading,
|
||||
)
|
||||
}
|
||||
if (state.notificationPermissionDenied) {
|
||||
Button(
|
||||
onClick = onOpenNotificationSettings,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = TppColors.Navy),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Text("Przejdź do ustawień", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
@@ -937,14 +1111,35 @@ private fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPostNotifications(context: Context): Boolean =
|
||||
hasPostNotificationsRuntimePermission(context) &&
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
|
||||
private fun hasPostNotificationsRuntimePermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun openAppNotificationSettings(context: Context) {
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
} else {
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.setData(Uri.parse("package:${context.packageName}"))
|
||||
}
|
||||
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
|
||||
@Composable
|
||||
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,6 +1222,9 @@ private fun DetailScreen(
|
||||
val route = state.selectedRoute
|
||||
val canManagePhotos = canManageRoutePhotos(state.selectedDate)
|
||||
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
DetailHeader(onBack)
|
||||
@@ -1037,7 +1235,7 @@ private fun DetailScreen(
|
||||
Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
item { ManifestSection(route, onNavigate = { openNavigation(context, it) }) }
|
||||
@@ -1094,6 +1292,7 @@ private fun DetailScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCompleteDialog && route != null) {
|
||||
AlertDialog(
|
||||
@@ -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,17 +1409,11 @@ 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)) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = onComplete,
|
||||
enabled = isOnline && !completing,
|
||||
modifier = Modifier.fillMaxWidth().height(58.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(64.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = TppColors.Forest,
|
||||
disabledContainerColor = TppColors.Outline,
|
||||
@@ -1247,10 +1442,10 @@ private fun RouteCompletionSection(
|
||||
color = TppColors.Muted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun photoCountLabel(count: Int): String =
|
||||
@@ -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,7 +1565,16 @@ private fun RoutePointBlock(
|
||||
|
||||
@Composable
|
||||
private fun RouteFactsFooter(route: DriverRouteDto) {
|
||||
Column(Modifier.fillMaxWidth().background(TppColors.Panel).padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().background(TppColors.Panel)) {
|
||||
val columns = routeFactColumns(maxWidth.value.toInt())
|
||||
|
||||
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
if (columns == 1) {
|
||||
RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.fillMaxWidth())
|
||||
RouteFact("Pojazd", route.truckLabel(), Modifier.fillMaxWidth())
|
||||
RouteFact("Nr kontraktu", route.contractCode ?: "#${route.id}", Modifier.fillMaxWidth())
|
||||
RouteFact("Dystans", route.distanceLabel(), Modifier.fillMaxWidth())
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
RouteFact("Typ ładunku", route.contractName ?: route.contractorName, Modifier.weight(1f))
|
||||
RouteFact("Pojazd", route.truckLabel(), Modifier.weight(1f))
|
||||
@@ -1380,6 +1584,8 @@ private fun RouteFactsFooter(route: DriverRouteDto) {
|
||||
RouteFact("Dystans", route.distanceLabel(), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1462,7 +1668,12 @@ private fun PhotoGrid(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, TppColors.Outline),
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
val compactWidth = maxWidth < 340.dp
|
||||
val contentPadding = if (compactWidth) 12.dp else 20.dp
|
||||
val gridGap = if (compactWidth) 8.dp else 12.dp
|
||||
|
||||
Column(Modifier.padding(contentPadding), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"Załączone zdjęcia ($visibleAttachmentCount)",
|
||||
color = TppColors.Muted,
|
||||
@@ -1472,11 +1683,11 @@ private fun PhotoGrid(
|
||||
if (items.isEmpty()) {
|
||||
EmptyPhotoState()
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
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(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(gridGap), modifier = Modifier.fillMaxWidth()) {
|
||||
rowItems.forEach { item ->
|
||||
when (item) {
|
||||
is PhotoGridItem.Server -> PhotoTile(
|
||||
@@ -1506,6 +1717,7 @@ private fun PhotoGrid(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingDelete?.let { target ->
|
||||
AlertDialog(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<PhotoUploadEntity> = 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) {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#07172B" />
|
||||
</shape>
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FFFFFF"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package pl.firmatpp.kierowca.sync
|
||||
|
||||
import java.time.LocalDate
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pl.firmatpp.kierowca.data.model.DriverRouteDto
|
||||
|
||||
class NewRouteNotificationFormatterTest {
|
||||
@Test
|
||||
fun formatsHumanRouteDates() {
|
||||
val today = LocalDate.of(2026, 7, 2)
|
||||
|
||||
assertEquals("Nowy kurs: dzisiaj", NewRouteNotificationFormatter.title("2026-07-02", today))
|
||||
assertEquals("Nowy kurs: jutro", NewRouteNotificationFormatter.title("2026-07-03", today))
|
||||
assertEquals("Nowy kurs: w sobotę", NewRouteNotificationFormatter.title("2026-07-04", today))
|
||||
assertEquals("Nowy kurs: 12 lipca", NewRouteNotificationFormatter.title("2026-07-12", today))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun formatsCollapsedAndExpandedRouteText() {
|
||||
val route = DriverRouteDto(
|
||||
id = "42",
|
||||
routeDate = "2026-07-03",
|
||||
startsAt = "09:15",
|
||||
originName = "TPP Baza Krakow",
|
||||
destinationName = "Instalacja Odpadowa Slask",
|
||||
contractorName = "TPP Baza Krakow",
|
||||
contractName = "Odpady przemyslowe",
|
||||
contractCode = "TPP-ODP-24",
|
||||
relationLabel = "TPP Baza Krakow -> Instalacja Odpadowa Slask",
|
||||
status = "ZAPLANOWANA",
|
||||
distanceKm = 91.4,
|
||||
notes = null,
|
||||
truck = null,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"TPP Baza Krakow → Instalacja Odpadowa Slask · Odpady przemyslowe",
|
||||
NewRouteNotificationFormatter.collapsedText(route),
|
||||
)
|
||||
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Skąd: TPP Baza Krakow"))
|
||||
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Dokąd: Instalacja Odpadowa Slask"))
|
||||
assertTrue(NewRouteNotificationFormatter.expandedText(route).contains("Kontrakt: Odpady przemyslowe"))
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,38 @@ class DriverUiRulesTest {
|
||||
assertEquals(3, inlinePhotoGridRows(5))
|
||||
}
|
||||
|
||||
@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))
|
||||
|
||||
Reference in New Issue
Block a user