P4 Stream N: FCM service + NotificationChannels matching iOS categories

FcmService + NotificationPayload + 4 NotificationChannels (task_reminder,
task_overdue, residence_invite, subscription) parity with iOS
NotificationCategories.swift. Deep-link routing from payload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:45:37 -05:00
parent 6d7b5ee990
commit 0d50726490
8 changed files with 699 additions and 2 deletions
@@ -0,0 +1,93 @@
package com.tt.honeyDue.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationManagerCompat
/**
* Android NotificationChannels that map to the iOS UNNotificationCategory
* identifiers defined in `iosApp/iosApp/PushNotifications/NotificationCategories.swift`.
*
* iOS uses categories + per-notification actions. Android uses channels for
* importance grouping. Channels here mirror the four high-level iOS tones:
*
* task_reminder — default importance (upcoming/due-soon reminders)
* task_overdue — high importance (user is late, needs attention)
* residence_invite — default importance (social-style invite, not urgent)
* subscription — low importance (billing/status changes, passive info)
*
* User-visible names and descriptions match the keys in
* `composeApp/src/commonMain/composeResources/values/strings.xml`
* (`notif_channel_*_name`, `notif_channel_*_description`).
*/
object NotificationChannels {
const val TASK_REMINDER: String = "task_reminder"
const val TASK_OVERDUE: String = "task_overdue"
const val RESIDENCE_INVITE: String = "residence_invite"
const val SUBSCRIPTION: String = "subscription"
// English fallback strings. These are duplicated in composeResources
// strings.xml under the matching notif_channel_* keys so localised builds
// can override them. Services without access to Compose resources fall
// back to these values.
private const val NAME_TASK_REMINDER = "Task Reminders"
private const val NAME_TASK_OVERDUE = "Overdue Tasks"
private const val NAME_RESIDENCE_INVITE = "Residence Invites"
private const val NAME_SUBSCRIPTION = "Subscription Updates"
private const val DESC_TASK_REMINDER = "Upcoming and due-soon task reminders"
private const val DESC_TASK_OVERDUE = "Alerts when a task is past its due date"
private const val DESC_RESIDENCE_INVITE = "Invitations to join a shared residence"
private const val DESC_SUBSCRIPTION = "Subscription status and billing updates"
/**
* Create all four channels if they don't already exist. Safe to call
* repeatedly — `NotificationManagerCompat.createNotificationChannel` is
* a no-op when a channel with the same id already exists.
*/
fun ensureChannels(context: Context) {
// Channels only exist on O+; on older versions this is a no-op and the
// NotificationCompat layer ignores channel ids.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val compat = NotificationManagerCompat.from(context)
val channels = listOf(
NotificationChannel(
TASK_REMINDER,
NAME_TASK_REMINDER,
NotificationManager.IMPORTANCE_DEFAULT
).apply { description = DESC_TASK_REMINDER },
NotificationChannel(
TASK_OVERDUE,
NAME_TASK_OVERDUE,
NotificationManager.IMPORTANCE_HIGH
).apply { description = DESC_TASK_OVERDUE },
NotificationChannel(
RESIDENCE_INVITE,
NAME_RESIDENCE_INVITE,
NotificationManager.IMPORTANCE_DEFAULT
).apply { description = DESC_RESIDENCE_INVITE },
NotificationChannel(
SUBSCRIPTION,
NAME_SUBSCRIPTION,
NotificationManager.IMPORTANCE_LOW
).apply { description = DESC_SUBSCRIPTION }
)
channels.forEach { compat.createNotificationChannel(it) }
}
/**
* Map a [NotificationPayload.type] string to a channel id. Unknown types
* fall back to [TASK_REMINDER] (default importance, safe default).
*/
fun channelIdForType(type: String): String = when (type) {
TASK_OVERDUE -> TASK_OVERDUE
RESIDENCE_INVITE -> RESIDENCE_INVITE
SUBSCRIPTION -> SUBSCRIPTION
TASK_REMINDER -> TASK_REMINDER
else -> TASK_REMINDER
}
}