diff --git a/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt b/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt new file mode 100644 index 0000000..d3df921 --- /dev/null +++ b/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt @@ -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() + 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 { + 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) + } +} diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt index 15c1d90..12c0495 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt @@ -9,45 +9,41 @@ import kotlinx.coroutines.launch 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. * - 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. + * (Skip stays a per-dose UI action; the notification never offers it.) */ class DoseActionReceiver : BroadcastReceiver() { companion object { const val ACTION_TAKEN = "no.naiv.meddetsamme.action.TAKEN" 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 EXTRA_LOG_ID = "logId" + const val EXTRA_SCHEDULED_AT = "scheduledAt" } override fun onReceive(context: Context, intent: Intent) { - val logId = intent.getLongExtra(EXTRA_LOG_ID, -1) - if (logId < 0) return + val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1) + if (scheduledAt < 0) return val action = intent.action ?: return val pending = goAsync() CoroutineScope(Dispatchers.IO).launch { 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 { 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) - } - } } diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt index 2d68494..17df536 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt @@ -80,11 +80,7 @@ class DoseAlarmReceiver : BroadcastReceiver() { else -> existing } - Notifications.post( - context, - Notifications.doseNotificationId(log.id), - Notifications.buildDoseNotification(context, med, log), - ) + postOccurrence(context, db, scheduledAt) // Start the automatic re-nag chain only if the user wants repeats; an // explicit Snooze still re-arms regardless (DoseActions.snooze). if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) { @@ -102,15 +98,11 @@ class DoseAlarmReceiver : BroadcastReceiver() { // depth against any path that arms one too early). if (log.scheduledAtMillis > System.currentTimeMillis()) return - val med = db.medicationDao().getById(log.medId) ?: return val nagged = log.copy(nagCount = log.nagCount + 1) db.doseLogDao().setNagCount(logId, nagged.nagCount) - Notifications.post( - context, - Notifications.doseNotificationId(logId), - Notifications.buildDoseNotification(context, med, nagged), - ) + // Re-post the whole occurrence so co-timed siblings stay on one card. + postOccurrence(context, db, log.scheduledAtMillis) // 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 // 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 = no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders } diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt index b3adb77..0d96035 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt @@ -22,6 +22,22 @@ interface DoseLogDao { @Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis") suspend fun getUnresolved(): List + /** + * 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 + @Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis") fun observeRange(fromMillis: Long, toMillis: Long): Flow> diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt index e9a2f68..bed4232 100644 --- a/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt +++ b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt @@ -35,42 +35,44 @@ class DoseActions(private val context: Context) { return db.doseLogDao().getById(id)!! } + // ---- single-dose actions (the app's Today screen acts per dose) -------- + suspend fun take(logId: Long) { val log = db.doseLogDao().getById(logId) ?: return - if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice - db.doseLogDao().setStatus(logId, DoseStatus.TAKEN, System.currentTimeMillis()) - db.medicationDao().getRawById(log.medId)?.let { - db.inventoryDao().decrementStock(it.itemId, log.amount) - } - scheduler.cancelEscalation(logId) - Notifications.cancel(context, Notifications.doseNotificationId(logId)) + takeState(log) + refreshOccurrence(log.scheduledAtMillis) } suspend fun snooze(logId: Long) { - db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis()) - scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES) - Notifications.cancel(context, Notifications.doseNotificationId(logId)) + val log = db.doseLogDao().getById(logId) ?: return + snoozeState(log) + // 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) { + val log = db.doseLogDao().getById(logId) ?: return db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis()) scheduler.cancelEscalation(logId) - Notifications.cancel(context, Notifications.doseNotificationId(logId)) + refreshOccurrence(log.scheduledAtMillis) } - /** - * "Ikke forstyrr": stop nagging this dose but leave it unresolved — the user - * takes responsibility to remember it. Reuses the existing terminal state - * (nagCount at the cap) rather than a new flag, so escalation stops *and* - * armAll() won't resume it after a reboot (it re-arms only nagCount < cap). - * The dose stays PENDING, so it can still be taken from the app later. - */ - suspend fun mute(logId: Long) { - val log = db.doseLogDao().getById(logId) ?: return - if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return - db.doseLogDao().setNagCount(logId, AlarmScheduler.NAG_CAP) - scheduler.cancelEscalation(logId) - Notifications.cancel(context, Notifications.doseNotificationId(logId)) + // ---- occurrence (group) actions — the notification buttons ------------- + // Every med due at the same wall-clock minute, handled in one tap. + + suspend fun takeAll(scheduledAtMillis: Long) { + activeAt(scheduledAtMillis).forEach { takeState(it) } + refreshOccurrence(scheduledAtMillis) + } + + suspend fun snoozeAll(scheduledAtMillis: Long) { + activeAt(scheduledAtMillis).forEach { snoozeState(it) } + Notifications.cancel(context, Notifications.doseNotificationId(scheduledAtMillis)) + } + + 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. */ @@ -87,4 +89,62 @@ class DoseActions(private val context: Context) { db.doseLogDao().setNagCount(logId, 0) 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 = + 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)) + } } diff --git a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt index c9e088b..4561f9e 100644 --- a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt +++ b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt @@ -23,8 +23,14 @@ 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() + /** + * 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 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 - * replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting - * is the escalation). + * The dose reminder card for one occurrence. [entries] are all the meds due + * at the same wall-clock minute (one entry = a normal single card; several = + * 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 { - 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 buildDoseNotification( + context: Context, + entries: List>, + scheduledAtMillis: Long, + alertAgain: Boolean = true, + ): Notification { + 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, - // Distinct requestCode per (log, action); same (log, action) replaces. - (log.id * 10 + action.hashCode().mod(10)).toInt(), + // One requestCode per occurrence; the Intent action keeps the + // Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals). + doseNotificationId(scheduledAtMillis), Intent(context, DoseActionReceiver::class.java) - .setAction(action) - .putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id), + .setAction(actionStr) + .putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val contentIntent = PendingIntent.getActivity( context, - doseNotificationId(log.id), + doseNotificationId(scheduledAtMillis), Intent(context, MainActivity::class.java), 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) .setContentTitle(title) .setContentText(body) @@ -98,16 +134,22 @@ object Notifications { .setContentIntent(contentIntent) .setAutoCancel(false) .setOngoing(false) - .addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN)) - .addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE)) - // From the second reminder on, offer an escape hatch: stop nagging - // this dose without marking it skipped (the user takes over). - .apply { - if (log.nagCount >= 1) { - addAction(0, "Ikke forstyrr", action(DoseActionReceiver.ACTION_MUTE)) - } - } - .build() + .setOnlyAlertOnce(!alertAgain) + .addAction(0, if (grouped) "Tatt alle" else "Tatt", action(DoseActionReceiver.ACTION_TAKEN)) + .addAction(0, if (grouped) "Utsett alle" else "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE)) + if (grouped) { + builder.setStyle( + NotificationCompat.InboxStyle() + .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)) + } + return builder.build() } /** Morning low-stock digest: plainly dismissable, re-posted daily while low. */