Add new route push notifications

This commit is contained in:
admin
2026-07-02 15:25:54 +02:00
parent a3d39764e2
commit 64d6f5a41d
15 changed files with 1025 additions and 413 deletions
@@ -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,
)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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) {