Reminders: one grouped card for co-timed meds, acked together

Meds due at the same wall-clock minute now share a single notification
instead of one card each — they resolve to an identical scheduledAtMillis,
which becomes the occurrence (and notification) key. Per the feature
request: "Tatt alle" / "Utsett alle" handle the whole morning batch in
one tap; a single med just renders as a normal card with "Tatt".

- Notification id is per-occurrence (scheduledAtMillis), not per-log, so
  co-timed doses collapse onto one card and re-nags replace it.
- DoseActionReceiver buttons carry the occurrence and call group ops
  (takeAll/snoozeAll/muteAll); DoseActions keeps a single per-dose state
  mutation (takeState/snoozeState/muteState) reused by both the group ops
  and the app's per-dose actions, so the two surfaces can't diverge.
- The card is rebuilt from getActiveAtOccurrence: still-unresolved doses
  with nagCount <= cap. "Ikke forstyrr" now sets nagCount above the cap so
  a muted dose drops off the card, while a naturally-capped dose (== cap)
  stays visible and actionable.
- refreshOccurrence re-renders silently after a single in-app action and
  never creates a card for a not-yet-due occurrence.

Adds DoseActionsGroupTest (instrumented) covering takeAll, muteAll, and
the muted-vs-capped distinction at the data layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-06-29 14:28:18 +02:00
commit 44ea4ed87b
6 changed files with 309 additions and 82 deletions

View file

@ -0,0 +1,104 @@
package no.naiv.meddetsamme.domain
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import no.naiv.meddetsamme.alarm.AlarmScheduler
import no.naiv.meddetsamme.data.DoseLog
import no.naiv.meddetsamme.data.DoseStatus
import no.naiv.meddetsamme.data.DoseTime
import no.naiv.meddetsamme.data.InventoryItem
import no.naiv.meddetsamme.data.MedDatabase
import no.naiv.meddetsamme.data.MedForm
import no.naiv.meddetsamme.data.Medication
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* The grouped-notification contract at the data layer: co-timed doses (an
* identical scheduledAtMillis) form one occurrence, "Tatt alle" resolves them
* together, and "Ikke forstyrr alle" drops them off the card without marking
* them skipped. Drives DoseActions directly the single Taken/Snooze/Skip path
* since the notification surface itself can't be driven deterministically.
*/
@RunWith(AndroidJUnit4::class)
class DoseActionsGroupTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private val db get() = MedDatabase.get(context)
private val actions = DoseActions(context)
/** A fixed PAST instant both doses share — that shared value *is* the group. */
private val occurrence = 1_600_000_000_000L
@Before fun clear() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
@After fun cleanup() = runBlocking { db.restoreAll(emptyList(), emptyList(), emptyList(), emptyList()) }
/** One med + one dose log due at [occurrence]; returns (logId, itemId). */
private suspend fun seedDose(name: String, stock: Double, amount: Double): Pair<Long, Long> {
val itemId = db.inventoryDao().insert(
InventoryItem(name = name, strength = "", unit = "tablett", form = MedForm.TABLET, stockUnits = stock),
)
val medId = db.medicationDao().insert(Medication(itemId = itemId))
val doseTimeId = db.doseTimeDao().insert(DoseTime(medId = medId, minuteOfDay = 480, amount = amount))
val logId = db.doseLogDao().insert(
DoseLog(medId = medId, doseTimeId = doseTimeId, scheduledAtMillis = occurrence, amount = amount),
)
return logId to itemId
}
private suspend fun active() =
db.doseLogDao().getActiveAtOccurrence(occurrence, AlarmScheduler.NAG_CAP)
@Test fun takeAllResolvesEveryCoTimedDoseAndDecrementsEach() = runBlocking {
val (logA, _) = seedDose("A", stock = 10.0, amount = 1.0)
val (logB, _) = seedDose("B", stock = 20.0, amount = 2.0)
assertEquals("both due at the same instant are one occurrence", 2, active().size)
actions.takeAll(occurrence)
assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logA)!!.status)
assertEquals(DoseStatus.TAKEN, db.doseLogDao().getById(logB)!!.status)
assertTrue("nothing left to nag", active().isEmpty())
val stock = db.inventoryDao().getAll().associate { it.name to it.stockUnits }
assertEquals(9.0, stock["A"]!!, 1e-9)
assertEquals(18.0, stock["B"]!!, 1e-9)
}
@Test fun muteAllStopsNaggingButKeepsDosesPending() = runBlocking {
seedDose("A", 10.0, 1.0)
seedDose("B", 20.0, 1.0)
actions.muteAll(occurrence)
val logs = db.doseLogDao().getRange(occurrence, occurrence + 1)
assertTrue("muted doses stay unresolved, not skipped", logs.all { it.status == DoseStatus.PENDING })
assertTrue("nagCount above the cap = won't re-nag, won't resume on reboot", logs.all { it.nagCount > AlarmScheduler.NAG_CAP })
assertTrue("muted doses drop off the card", active().isEmpty())
}
@Test fun mutedDoseLeavesCoTimedSiblingActive() = runBlocking {
val (logA, _) = seedDose("A", 10.0, 1.0)
val (logB, _) = seedDose("B", 10.0, 1.0)
// Mute only A (its nagCount above the cap); B is untouched.
db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP + 1)
val remaining = active()
assertEquals(1, remaining.size)
assertEquals("B still nags; A is silenced", logB, remaining.single().id)
}
@Test fun naturallyCappedDoseStaysActionableOnCard() = runBlocking {
val (logA, _) = seedDose("A", 10.0, 1.0)
// Reached the nag cap on its own — still shown (== cap), unlike a mute.
db.doseLogDao().setNagCount(logA, AlarmScheduler.NAG_CAP)
assertEquals(1, active().size)
}
}

View file

@ -9,45 +9,41 @@ import kotlinx.coroutines.launch
import no.naiv.meddetsamme.domain.DoseActions import no.naiv.meddetsamme.domain.DoseActions
/** /**
* Handles the notification actions (and later, the same actions from the UI). * Handles the notification actions. The dose card groups every med due at the
* same wall-clock minute, so its buttons act on the whole occurrence (carried
* as EXTRA_SCHEDULED_AT), not a single log "Tatt alle" / "Utsett alle".
* *
* Contract (CLAUDE.md, load-bearing): * Contract (CLAUDE.md, load-bearing), applied to each dose in the occurrence:
* - Taken TAKEN, decrement inventory by the *logged* amount, stop escalating. * - Taken TAKEN, decrement inventory by the *logged* amount, stop escalating.
* - Snooze SNOOZED, next nag in 15 min, keeps escalating (and counting) after. * - Snooze SNOOZED, next nag in 15 min, keeps escalating (and counting) after.
* - Skip SKIPPED, stop escalating, no inventory change.
* - Mute "Ikke forstyrr": stop escalating, dose stays unresolved. * - Mute "Ikke forstyrr": stop escalating, dose stays unresolved.
* (Skip stays a per-dose UI action; the notification never offers it.)
*/ */
class DoseActionReceiver : BroadcastReceiver() { class DoseActionReceiver : BroadcastReceiver() {
companion object { companion object {
const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN" const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN"
const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE" const val ACTION_SNOOZE = "no.naiv.meddetsamme.action.SNOOZE"
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE" const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE"
const val EXTRA_LOG_ID = "logId" const val EXTRA_SCHEDULED_AT = "scheduledAt"
} }
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1) val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1)
if (logId < 0) return if (scheduledAt < 0) return
val action = intent.action ?: return val action = intent.action ?: return
val pending = goAsync() val pending = goAsync()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
handle(context, action, logId) val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> actions.takeAll(scheduledAt)
ACTION_SNOOZE -> actions.snoozeAll(scheduledAt)
ACTION_MUTE -> actions.muteAll(scheduledAt)
}
} finally { } finally {
pending.finish() pending.finish()
} }
} }
} }
private suspend fun handle(context: Context, action: String, logId: Long) {
val actions = DoseActions(context)
when (action) {
ACTION_TAKEN -> actions.take(logId)
ACTION_SNOOZE -> actions.snooze(logId)
ACTION_SKIP -> actions.skip(logId)
ACTION_MUTE -> actions.mute(logId)
}
}
} }

View file

@ -80,11 +80,7 @@ class DoseAlarmReceiver : BroadcastReceiver() {
else -> existing else -> existing
} }
Notifications.post( postOccurrence(context, db, scheduledAt)
context,
Notifications.doseNotificationId(log.id),
Notifications.buildDoseNotification(context, med, log),
)
// Start the automatic re-nag chain only if the user wants repeats; an // Start the automatic re-nag chain only if the user wants repeats; an
// explicit Snooze still re-arms regardless (DoseActions.snooze). // explicit Snooze still re-arms regardless (DoseActions.snooze).
if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) { if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) {
@ -102,15 +98,11 @@ class DoseAlarmReceiver : BroadcastReceiver() {
// depth against any path that arms one too early). // depth against any path that arms one too early).
if (log.scheduledAtMillis > System.currentTimeMillis()) return if (log.scheduledAtMillis > System.currentTimeMillis()) return
val med = db.medicationDao().getById(log.medId) ?: return
val nagged = log.copy(nagCount = log.nagCount + 1) val nagged = log.copy(nagCount = log.nagCount + 1)
db.doseLogDao().setNagCount(logId, nagged.nagCount) db.doseLogDao().setNagCount(logId, nagged.nagCount)
Notifications.post( // Re-post the whole occurrence so co-timed siblings stay on one card.
context, postOccurrence(context, db, log.scheduledAtMillis)
Notifications.doseNotificationId(logId),
Notifications.buildDoseNotification(context, med, nagged),
)
// Re-arm the next automatic nag only while repeats are enabled. A nag // Re-arm the next automatic nag only while repeats are enabled. A nag
// already in flight from a Snooze still fires once (this post), then // already in flight from a Snooze still fires once (this post), then
// stops here — Snooze stays a single deferral, not an endless chain. // stops here — Snooze stays a single deferral, not an endless chain.
@ -119,6 +111,23 @@ class DoseAlarmReceiver : BroadcastReceiver() {
} }
} }
/**
* Post (or replace) the single grouped card for one wall-clock occurrence:
* every med due at that minute, still active (not taken/skipped/muted). If
* nothing's active it clears the card a redundant escalation that raced a
* "Tatt alle" then just tidies up.
*/
private suspend fun postOccurrence(context: Context, db: MedDatabase, scheduledAtMillis: Long) {
val active = db.doseLogDao().getActiveAtOccurrence(scheduledAtMillis, AlarmScheduler.NAG_CAP)
val id = Notifications.doseNotificationId(scheduledAtMillis)
val entries = active.mapNotNull { l -> db.medicationDao().getById(l.medId)?.let { it to l } }
if (entries.isEmpty()) {
Notifications.cancel(context, id)
return
}
Notifications.post(context, id, Notifications.buildDoseNotification(context, entries, scheduledAtMillis))
}
private fun repeatReminders(context: Context): Boolean = private fun repeatReminders(context: Context): Boolean =
no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders
} }

View file

@ -22,6 +22,22 @@ interface DoseLogDao {
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis") @Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
suspend fun getUnresolved(): List<DoseLog> suspend fun getUnresolved(): List<DoseLog>
/**
* Co-timed doses to show on one card: same wall-clock occurrence, still
* unresolved, nagCount <= [maxNag]. Pass NAG_CAP so a naturally-exhausted
* dose (== cap) still shows and stays actionable, while "Ikke forstyrr"
* (which sets nagCount above the cap) drops out otherwise a muted dose
* would reappear via a co-timed sibling's re-nag.
*/
@Query(
"""SELECT * FROM dose_log
WHERE scheduledAtMillis = :scheduledAtMillis
AND status IN ('PENDING', 'SNOOZED')
AND nagCount <= :maxNag
ORDER BY id""",
)
suspend fun getActiveAtOccurrence(scheduledAtMillis: Long, maxNag: Int): List<DoseLog>
@Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis") @Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis")
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>> fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>

View file

@ -35,42 +35,44 @@ class DoseActions(private val context: Context) {
return db.doseLogDao().getById(id)!! return db.doseLogDao().getById(id)!!
} }
// ---- single-dose actions (the app's Today screen acts per dose) --------
suspend fun take(logId: Long) { suspend fun take(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return val log = db.doseLogDao().getById(logId) ?: return
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice takeState(log)
db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, System.currentTimeMillis()) refreshOccurrence(log.scheduledAtMillis)
db.medicationDao().getRawById(log.medId)?.let {
db.inventoryDao().decrementStock(it.itemId, log.amount)
}
scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId))
} }
suspend fun snooze(logId: Long) { suspend fun snooze(logId: Long) {
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis()) val log = db.doseLogDao().getById(logId) ?: return
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES) snoozeState(log)
Notifications.cancel(context, Notifications.doseNotificationId(logId)) // Snooze dismisses now; the re-nag brings the card back in 15 min.
Notifications.cancel(context, Notifications.doseNotificationId(log.scheduledAtMillis))
} }
suspend fun skip(logId: Long) { suspend fun skip(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return
db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis()) db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis())
scheduler.cancelEscalation(logId) scheduler.cancelEscalation(logId)
Notifications.cancel(context, Notifications.doseNotificationId(logId)) refreshOccurrence(log.scheduledAtMillis)
} }
/** // ---- occurrence (group) actions — the notification buttons -------------
* "Ikke forstyrr": stop nagging this dose but leave it unresolved the user // Every med due at the same wall-clock minute, handled in one tap.
* takes responsibility to remember it. Reuses the existing terminal state
* (nagCount at the cap) rather than a new flag, so escalation stops *and* suspend fun takeAll(scheduledAtMillis: Long) {
* armAll() won't resume it after a reboot (it re-arms only nagCount < cap). activeAt(scheduledAtMillis).forEach { takeState(it) }
* The dose stays PENDING, so it can still be taken from the app later. refreshOccurrence(scheduledAtMillis)
*/ }
suspend fun mute(logId: Long) {
val log = db.doseLogDao().getById(logId) ?: return suspend fun snoozeAll(scheduledAtMillis: Long) {
if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return activeAt(scheduledAtMillis).forEach { snoozeState(it) }
db.doseLogDao().setNagCount(logId, AlarmScheduler.NAG_CAP) Notifications.cancel(context, Notifications.doseNotificationId(scheduledAtMillis))
scheduler.cancelEscalation(logId) }
Notifications.cancel(context, Notifications.doseNotificationId(logId))
suspend fun muteAll(scheduledAtMillis: Long) {
activeAt(scheduledAtMillis).forEach { muteState(it) }
refreshOccurrence(scheduledAtMillis)
} }
/** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */ /** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */
@ -87,4 +89,62 @@ class DoseActions(private val context: Context) {
db.doseLogDao().setNagCount(logId, 0) db.doseLogDao().setNagCount(logId, 0)
scheduler.cancelEscalation(logId) scheduler.cancelEscalation(logId)
} }
// ---- shared state mutations (single source of truth per dose) ----------
private suspend fun takeState(log: DoseLog) {
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
db.doseLogDao().setStatus(log.id, DoseStatus.TAKEN, System.currentTimeMillis())
db.medicationDao().getRawById(log.medId)?.let {
db.inventoryDao().decrementStock(it.itemId, log.amount)
}
scheduler.cancelEscalation(log.id)
}
private suspend fun snoozeState(log: DoseLog) {
db.doseLogDao().setStatus(log.id, DoseStatus.SNOOZED, System.currentTimeMillis())
scheduler.armEscalation(log.id, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
}
/**
* "Ikke forstyrr": stop nagging this dose but leave it unresolved the user
* takes over. Setting nagCount *above* the cap stops escalation, keeps
* armAll() from resuming it after a reboot (it re-arms only nagCount < cap),
* and drops it from the grouped card while a naturally-capped dose
* (== cap) stays visible and actionable. Status stays PENDING, so the dose
* can still be taken from the app later.
*/
private suspend fun muteState(log: DoseLog) {
if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return
db.doseLogDao().setNagCount(log.id, AlarmScheduler.NAG_CAP + 1)
scheduler.cancelEscalation(log.id)
}
private suspend fun activeAt(scheduledAtMillis: Long): List<DoseLog> =
db.doseLogDao().getActiveAtOccurrence(scheduledAtMillis, AlarmScheduler.NAG_CAP)
/**
* Re-render the single occurrence card to match DB state, or clear it when
* nothing's left to act on. Silent (no re-alert) this fires after the user
* already handled a dose, so buzzing for the untouched ones would be wrong.
* Never *creates* a card for a not-yet-due occurrence (taking tomorrow's
* dose early from the app must not post a reminder for today).
*/
private suspend fun refreshOccurrence(scheduledAtMillis: Long) {
val id = Notifications.doseNotificationId(scheduledAtMillis)
val active = activeAt(scheduledAtMillis)
if (active.isEmpty()) {
Notifications.cancel(context, id)
return
}
if (scheduledAtMillis > System.currentTimeMillis()) return // future: no card to refresh
val entries = active.mapNotNull { log ->
db.medicationDao().getById(log.medId)?.let { it to log }
}
if (entries.isEmpty()) {
Notifications.cancel(context, id)
return
}
Notifications.post(context, id, Notifications.buildDoseNotification(context, entries, scheduledAtMillis, alertAgain = false))
}
} }

View file

@ -23,8 +23,14 @@ object Notifications {
const val CHANNEL_DOSES = "doses" const val CHANNEL_DOSES = "doses"
const val CHANNEL_SUPPLY = "supply" 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() * Stable per-OCCURRENCE notification id (minute resolution): every med due
* at the same wall-clock minute shares one card, and every re-nag replaces
* it. Keyed on scheduledAtMillis because co-timed doses resolve to an
* identical value that's the whole grouping mechanism.
*/
fun doseNotificationId(scheduledAtMillis: Long): Int =
((scheduledAtMillis / 60_000) % Int.MAX_VALUE).toInt()
const val SUPPLY_NOTIFICATION_ID = 1_000_000_007 const val SUPPLY_NOTIFICATION_ID = 1_000_000_007
const val RX_NOTIFICATION_ID = 1_000_000_009 const val RX_NOTIFICATION_ID = 1_000_000_009
@ -59,37 +65,67 @@ object Notifications {
} }
/** /**
* The dose reminder card. nagCount > 0 marks a re-nag same id, so it * The dose reminder card for one occurrence. [entries] are all the meds due
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting * at the same wall-clock minute (one entry = a normal single card; several =
* is the escalation). * a grouped card with "alle" actions). Actions carry the scheduledAtMillis,
* not a log id, so they act on the whole occurrence.
*
* [alertAgain] = false (setOnlyAlertOnce) is for silent in-place refreshes
* after a single dose is handled in the app re-alerting there would buzz
* for the doses you *didn't* touch. Fire and escalation leave it true:
* re-alerting is the escalation.
*/ */
fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification { fun buildDoseNotification(
val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault()) context: Context,
val title = med.displayName entries: List<Pair<MedWithItem, DoseLog>>,
val body = buildString { scheduledAtMillis: Long,
append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}") alertAgain: Boolean = true,
if (med.med.withFood) append(" — tas med mat") ): Notification {
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})") require(entries.isNotEmpty()) { "Dose notification needs at least one med" }
val time = timeFmt.format(Instant.ofEpochMilli(scheduledAtMillis).atZone(ZoneId.systemDefault()))
val grouped = entries.size > 1
val maxNag = entries.maxOf { it.second.nagCount }
val nagSuffix = if (maxNag > 0) " (påminnelse $maxNag)" else ""
fun lineFor(med: MedWithItem, log: DoseLog) = buildString {
append("${med.displayName}: ${formatAmount(log.amount)} ${med.item.unit}")
if (med.med.withFood) append(" — med mat")
} }
fun action(action: String): PendingIntent = PendingIntent.getBroadcast( val title: String
val body: String
if (grouped) {
title = "${entries.size} medisiner kl. $time"
body = entries.joinToString(" · ") { (m, l) -> "${m.displayName} ${formatAmount(l.amount)} ${m.item.unit}" } + nagSuffix
} else {
val (m, l) = entries.first()
title = m.displayName
body = buildString {
append("${formatAmount(l.amount)} ${m.item.unit} kl. $time")
if (m.med.withFood) append(" — tas med mat")
append(nagSuffix)
}
}
fun action(actionStr: String): PendingIntent = PendingIntent.getBroadcast(
context, context,
// Distinct requestCode per (log, action); same (log, action) replaces. // One requestCode per occurrence; the Intent action keeps the
(log.id * 10 + action.hashCode().mod(10)).toInt(), // Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals).
doseNotificationId(scheduledAtMillis),
Intent(context, DoseActionReceiver::class.java) Intent(context, DoseActionReceiver::class.java)
.setAction(action) .setAction(actionStr)
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id), .putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
val contentIntent = PendingIntent.getActivity( val contentIntent = PendingIntent.getActivity(
context, context,
doseNotificationId(log.id), doseNotificationId(scheduledAtMillis),
Intent(context, MainActivity::class.java), Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
return NotificationCompat.Builder(context, CHANNEL_DOSES) val builder = NotificationCompat.Builder(context, CHANNEL_DOSES)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
@ -98,16 +134,22 @@ object Notifications {
.setContentIntent(contentIntent) .setContentIntent(contentIntent)
.setAutoCancel(false) .setAutoCancel(false)
.setOngoing(false) .setOngoing(false)
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN)) .setOnlyAlertOnce(!alertAgain)
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE)) .addAction(0, if (grouped) "Tatt alle" else "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
// From the second reminder on, offer an escape hatch: stop nagging .addAction(0, if (grouped) "Utsett alle" else "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
// this dose without marking it skipped (the user takes over). if (grouped) {
.apply { builder.setStyle(
if (log.nagCount >= 1) { NotificationCompat.InboxStyle()
addAction(0, "Ikke forstyrr", action(DoseActionReceiver.ACTION_MUTE)) .setBigContentTitle("Medisiner kl. $time$nagSuffix")
.also { s -> entries.forEach { (m, l) -> s.addLine(lineFor(m, l)) } },
)
} }
// From the second reminder on, offer an escape hatch: stop nagging this
// occurrence without marking it skipped (the user takes over).
if (maxNag >= 1) {
builder.addAction(0, if (grouped) "Ikke forstyrr alle" else "Ikke forstyrr", action(DoseActionReceiver.ACTION_MUTE))
} }
.build() return builder.build()
} }
/** Morning low-stock digest: plainly dismissable, re-posted daily while low. */ /** Morning low-stock digest: plainly dismissable, re-posted daily while low. */