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
|
||||
// Bump both for every release installed on the phone — versionName for
|
||||
// humans, versionCode so dumpsys/install history can tell builds apart.
|
||||
versionCode = 5
|
||||
versionName = "0.5.0"
|
||||
versionCode = 6
|
||||
versionName = "0.6.0"
|
||||
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 —
|
||||
// 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
|
||||
// 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()
|
||||
db.doseLogDao().getUnresolved().forEach { log ->
|
||||
if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) {
|
||||
|
|
|
|||
|
|
@ -9,42 +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 EXTRA_LOG_ID = "logId"
|
||||
const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE"
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,12 +80,12 @@ class DoseAlarmReceiver : BroadcastReceiver() {
|
|||
else -> existing
|
||||
}
|
||||
|
||||
Notifications.post(
|
||||
context,
|
||||
Notifications.doseNotificationId(log.id),
|
||||
Notifications.buildDoseNotification(context, med, log),
|
||||
)
|
||||
if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id)
|
||||
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) {
|
||||
scheduler.armEscalation(log.id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun escalate(context: Context, logId: Long) {
|
||||
|
|
@ -98,15 +98,36 @@ 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),
|
||||
)
|
||||
if (nagged.nagCount < AlarmScheduler.NAG_CAP) AlarmScheduler(context).armEscalation(logId)
|
||||
// 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.
|
||||
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")
|
||||
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,27 +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)
|
||||
}
|
||||
|
||||
// ---- 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. */
|
||||
|
|
@ -72,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,9 +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))
|
||||
.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. */
|
||||
|
|
|
|||
|
|
@ -55,6 +55,17 @@ class SettingsStore(context: Context) {
|
|||
get() = prefs.getInt("supply_check_minute", 600)
|
||||
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. */
|
||||
val isS3Configured: Boolean
|
||||
get() = !s3Endpoint.isNullOrBlank() && !s3Bucket.isNullOrBlank() &&
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
|
|
@ -117,6 +118,7 @@ fun SettingsScreen(nav: Nav) {
|
|||
|
||||
HorizontalDivider()
|
||||
Text("Varsler", style = MaterialTheme.typography.titleMedium)
|
||||
RepeatRemindersRow(settings)
|
||||
SupplyCheckTimeRow(settings)
|
||||
|
||||
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. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue