med-det-samme/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt
Ole-Morten Duesund 608d84236b Rx-expiry ongoing alert (7-day lead), configurable morning check, Felleskatalogen links
The daily digest splits in two: low stock stays an auto-cancel
notification re-posted each morning while below the per-item limit;
prescription renewal becomes a separate ONGOING notification starting
7 days before rx expiry — survives 'Clear all', swipe-dismissable on
Android 14+, returns each morning until the rx date is updated, and
cancels itself once renewed (post-or-cancel in SupplyCheckReceiver).
Check time is now configurable (Innstillinger → Varsler, M3 TimePicker,
default 10:00); same PendingIntent so re-arming replaces. 'Slå opp i
Felleskatalogen' buttons on the item editor and the med editor's
summary card open the browser — decision #8 clarified: the ban covers
Felleskatalogen as a data source, not human-facing links.

Emulator-verified: both notifications post with correct flags
(ONGOING_EVENT vs AUTO_CANCEL), auto-clear after renewal/restock,
alarm follows the configured time (10:00 → 07:30), FK button opens
Chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:21:25 +02:00

155 lines
6.3 KiB
Kotlin

package no.naiv.meddetsamme.notify
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import no.naiv.meddetsamme.MainActivity
import no.naiv.meddetsamme.R
import no.naiv.meddetsamme.alarm.DoseActionReceiver
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.MedWithItem
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
object Notifications {
const val CHANNEL_DOSES = "doses"
const val CHANNEL_SUPPLY = "supply"
/** Stable per-occurrence notification id: every re-nag *replaces* the card. */
fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt()
const val SUPPLY_NOTIFICATION_ID = 1_000_000_007
const val RX_NOTIFICATION_ID = 1_000_000_009
private val timeFmt = DateTimeFormatter.ofPattern("HH:mm")
fun createChannels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_DOSES,
"Doser",
// HIGH: heads-up + sound. The entire point of the app.
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "Påminnelser om å ta medisin"
setBypassDnd(false)
},
)
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_SUPPLY,
"Lager og resept",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Lite på lager eller resept som må fornyes"
},
)
}
/**
* The dose reminder card. nagCount > 0 marks a re-nag — same id, so it
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting
* is the escalation).
*/
fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification {
val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault())
val title = med.displayName
val body = buildString {
append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}")
if (med.med.withFood) append(" — tas med mat")
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
}
fun action(action: String): PendingIntent = PendingIntent.getBroadcast(
context,
// Distinct requestCode per (log, action); same (log, action) replaces.
(log.id * 10 + action.hashCode().mod(10)).toInt(),
Intent(context, DoseActionReceiver::class.java)
.setAction(action)
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val contentIntent = PendingIntent.getActivity(
context,
doseNotificationId(log.id),
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_DOSES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(contentIntent)
.setAutoCancel(false)
.setOngoing(false)
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
.build()
}
/** Morning low-stock digest: plainly dismissable, re-posted daily while low. */
fun buildLowStockNotification(context: Context, lines: List<String>): Notification =
supplyDigestBuilder(context, SUPPLY_NOTIFICATION_ID, lines)
.setContentTitle(if (lines.size == 1) lines.first() else "Lite på lager (${lines.size})")
.setAutoCancel(true)
.build()
/**
* Rx-renewal digest: setOngoing makes it survive "Clear all" — the
* "persistent but dismissable" contract (deliberate swipe still works on
* Android 14+, and the daily check re-posts it until the rx is renewed).
*/
fun buildRxNotification(context: Context, lines: List<String>): Notification =
supplyDigestBuilder(context, RX_NOTIFICATION_ID, lines)
.setContentTitle(if (lines.size == 1) lines.first() else "Resepter må fornyes (${lines.size})")
.setOngoing(true)
.build()
private fun supplyDigestBuilder(
context: Context,
requestCode: Int,
lines: List<String>,
): NotificationCompat.Builder {
val contentIntent = PendingIntent.getActivity(
context,
requestCode,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, CHANNEL_SUPPLY)
.setSmallIcon(R.drawable.ic_notification)
.setContentText(lines.joinToString(" · "))
.setStyle(NotificationCompat.InboxStyle().also { s -> lines.forEach(s::addLine) })
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setContentIntent(contentIntent)
}
/** Post if allowed; silently skipping is fine — channel-level blocks are user intent. */
fun post(context: Context, id: Int, notification: Notification) {
val nm = NotificationManagerCompat.from(context)
if (nm.areNotificationsEnabled()) {
try {
nm.notify(id, notification)
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked between check and post; nothing to do.
}
}
}
fun cancel(context: Context, id: Int) = NotificationManagerCompat.from(context).cancel(id)
private fun formatAmount(amount: Double): String =
if (amount == amount.toLong().toDouble()) amount.toLong().toString() else amount.toString()
}