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,53 @@
package com.tt.honeyDue.notifications
/**
* Structured representation of a Firebase Cloud Messaging data-payload for
* iOS-parity notification types (task_reminder, task_overdue, residence_invite,
* subscription).
*
* Mirrors the iOS `PushNotificationManager.swift` userInfo handling. The
* backend sends a `data` map only (no `notification` field) so we can always
* deliver actionable payloads regardless of app foreground state.
*/
data class NotificationPayload(
val type: String,
val taskId: Long?,
val residenceId: Long?,
val title: String,
val body: String,
val deepLink: String?
) {
companion object {
// Keys used by the backend. Kept in a single place so they can be updated
// in lockstep with the Go API `internal/notification/` constants.
private const val KEY_TYPE = "type"
private const val KEY_TASK_ID = "task_id"
private const val KEY_RESIDENCE_ID = "residence_id"
private const val KEY_TITLE = "title"
private const val KEY_BODY = "body"
private const val KEY_DEEP_LINK = "deep_link"
/**
* Parse a raw FCM data map into a [NotificationPayload], or null if the
* payload is missing the minimum required fields (type + at least one of
* title/body). Numeric id fields that fail to parse are treated as null
* (rather than failing the whole payload) so we still surface the text.
*/
fun parse(data: Map<String, String>): NotificationPayload? {
val type = data[KEY_TYPE]?.takeIf { it.isNotBlank() } ?: return null
val title = data[KEY_TITLE]?.takeIf { it.isNotBlank() }
val body = data[KEY_BODY]?.takeIf { it.isNotBlank() }
// Require at least one of title/body, otherwise there's nothing to show.
if (title == null && body == null) return null
return NotificationPayload(
type = type,
taskId = data[KEY_TASK_ID]?.toLongOrNull(),
residenceId = data[KEY_RESIDENCE_ID]?.toLongOrNull(),
title = title ?: "",
body = body ?: "",
deepLink = data[KEY_DEEP_LINK]?.takeIf { it.isNotBlank() }
)
}
}
}