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
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue