Compare commits
3 commits
5b9a3d6877
...
041f9e2cf6
| Author | SHA1 | Date | |
|---|---|---|---|
| 041f9e2cf6 | |||
| 44ea4ed87b | |||
| 778a6aec27 |
10 changed files with 372 additions and 66 deletions
|
|
@ -33,8 +33,8 @@ android {
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
// Bump both for every release installed on the phone — versionName for
|
// Bump both for every release installed on the phone — versionName for
|
||||||
// humans, versionCode so dumpsys/install history can tell builds apart.
|
// humans, versionCode so dumpsys/install history can tell builds apart.
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "0.5.0"
|
versionName = "0.6.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -90,7 +90,9 @@ class AlarmScheduler(private val context: Context) {
|
||||||
// Resume nagging for doses that were unresolved when the device died —
|
// Resume nagging for doses that were unresolved when the device died —
|
||||||
// but only PAST-DUE ones. An unresolved log can also be a future dose
|
// but only PAST-DUE ones. An unresolved log can also be a future dose
|
||||||
// (e.g. "Ta nå" + "Angre" before its time); its own occurrence alarm
|
// (e.g. "Ta nå" + "Angre" before its time); its own occurrence alarm
|
||||||
// handles it when it actually falls due.
|
// handles it when it actually falls due. Resuming the nag chain is the
|
||||||
|
// automatic repeat the setting governs, so honour it here too.
|
||||||
|
if (!no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders) return
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
db.doseLogDao().getUnresolved().forEach { log ->
|
db.doseLogDao().getUnresolved().forEach { log ->
|
||||||
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {
|
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {
|
||||||
|
|
|
||||||
|
|
@ -9,42 +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.
|
||||||
|
* (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 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,12 +80,12 @@ class DoseAlarmReceiver : BroadcastReceiver() {
|
||||||
else -> existing
|
else -> existing
|
||||||
}
|
}
|
||||||
|
|
||||||
Notifications.post(
|
postOccurrence(context, db, scheduledAt)
|
||||||
context,
|
// Start the automatic re-nag chain only if the user wants repeats; an
|
||||||
Notifications.doseNotificationId(log.id),
|
// explicit Snooze still re-arms regardless (DoseActions.snooze).
|
||||||
Notifications.buildDoseNotification(context, med, log),
|
if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) {
|
||||||
)
|
scheduler.armEscalation(log.id)
|
||||||
if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun escalate(context: Context, logId: Long) {
|
private suspend fun escalate(context: Context, logId: Long) {
|
||||||
|
|
@ -98,15 +98,36 @@ 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),
|
// Re-arm the next automatic nag only while repeats are enabled. A nag
|
||||||
Notifications.buildDoseNotification(context, med, nagged),
|
// already in flight from a Snooze still fires once (this post), then
|
||||||
)
|
// stops here — Snooze stays a single deferral, not an endless chain.
|
||||||
if (nagged.nagCount < AlarmScheduler.NAG_CAP) AlarmScheduler(context).armEscalation(logId)
|
if (repeatReminders(context) && nagged.nagCount < AlarmScheduler.NAG_CAP) {
|
||||||
|
AlarmScheduler(context).armEscalation(logId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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")
|
@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,27 +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 -------------
|
||||||
|
// 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. */
|
/** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */
|
||||||
|
|
@ -72,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,9 +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))
|
||||||
.build()
|
.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. */
|
/** Morning low-stock digest: plainly dismissable, re-posted daily while low. */
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,17 @@ class SettingsStore(context: Context) {
|
||||||
get() = prefs.getInt("supply_check_minute", 600)
|
get() = prefs.getInt("supply_check_minute", 600)
|
||||||
set(v) = prefs.edit { putInt("supply_check_minute", v.coerceIn(0, 1439)) }
|
set(v) = prefs.edit { putInt("supply_check_minute", v.coerceIn(0, 1439)) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a due dose re-nags automatically every ~10 min until resolved.
|
||||||
|
* Off → the dose notifies once and goes quiet (no escalation chain). An
|
||||||
|
* explicit "Utsett 15 min" still re-arms one reminder regardless — that's a
|
||||||
|
* user request, not the automatic repeat this gates. Default on: dropped
|
||||||
|
* doses are the failure mode this app exists to prevent.
|
||||||
|
*/
|
||||||
|
var repeatReminders: Boolean
|
||||||
|
get() = prefs.getBoolean("repeat_reminders", true)
|
||||||
|
set(v) = prefs.edit { putBoolean("repeat_reminders", v) }
|
||||||
|
|
||||||
/** S3/Garage credentials complete — gates the S3 upload path only. */
|
/** S3/Garage credentials complete — gates the S3 upload path only. */
|
||||||
val isS3Configured: Boolean
|
val isS3Configured: Boolean
|
||||||
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&
|
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TimePicker
|
import androidx.compose.material3.TimePicker
|
||||||
|
|
@ -117,6 +118,7 @@ fun SettingsScreen(nav: Nav) {
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
||||||
|
RepeatRemindersRow(settings)
|
||||||
SupplyCheckTimeRow(settings)
|
SupplyCheckTimeRow(settings)
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
|
@ -153,6 +155,33 @@ fun SettingsScreen(nav: Nav) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global toggle for automatic re-nagging. Off → a due dose alerts once and goes
|
||||||
|
* quiet (an explicit "Utsett 15 min" still gives one more reminder). Takes
|
||||||
|
* effect for the next dose that fires; no need to touch alarms already armed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RepeatRemindersRow(settings: SettingsStore) {
|
||||||
|
var enabled by remember { mutableStateOf(settings.repeatReminders) }
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Gjenta påminnelser")
|
||||||
|
Text(
|
||||||
|
"Mas hvert 10. min til dosen er tatt. Av: ett varsel, så stille.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = enabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
enabled = it
|
||||||
|
settings.repeatReminders = it
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Daily low-stock/rx notification time; re-arms the (replacing) alarm on change. */
|
/** Daily low-stock/rx notification time; re-arms the (replacing) alarm on change. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue