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:
parent
778a6aec27
commit
44ea4ed87b
6 changed files with 309 additions and 82 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,22 @@ interface DoseLogDao {
|
|||
@Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis")
|
||||
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")
|
||||
fun observeRange(fromMillis: Long, toMillis: Long): Flow<List<DoseLog>>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Pair<MedWithItem, DoseLog>>,
|
||||
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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue