Compare commits
No commits in common. "main" and "v0.5.0" have entirely different histories.
10 changed files with 66 additions and 372 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 = 6
|
versionCode = 5
|
||||||
versionName = "0.6.0"
|
versionName = "0.5.0"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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,9 +90,7 @@ 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. Resuming the nag chain is the
|
// handles it when it actually falls due.
|
||||||
// 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,41 +9,42 @@ import kotlinx.coroutines.launch
|
||||||
import no.naiv.meddetsamme.domain.DoseActions
|
import no.naiv.meddetsamme.domain.DoseActions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the notification actions. The dose card groups every med due at the
|
* Handles the notification actions (and later, the same actions from the UI).
|
||||||
* 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), applied to each dose in the occurrence:
|
* Contract (CLAUDE.md, load-bearing):
|
||||||
* - 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.
|
||||||
* - Mute → "Ikke forstyrr": stop escalating, dose stays unresolved.
|
* - Skip → SKIPPED, stop escalating, no inventory change.
|
||||||
* (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_MUTE = "no.naiv.meddetsamme.action.MUTE"
|
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
|
||||||
const val EXTRA_SCHEDULED_AT = "scheduledAt"
|
const val EXTRA_LOG_ID = "logId"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1)
|
val logId = intent.getLongExtra(EXTRA_LOG_ID, -1)
|
||||||
if (scheduledAt < 0) return
|
if (logId < 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 {
|
||||||
val actions = DoseActions(context)
|
handle(context, action, logId)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
postOccurrence(context, db, scheduledAt)
|
Notifications.post(
|
||||||
// Start the automatic re-nag chain only if the user wants repeats; an
|
context,
|
||||||
// explicit Snooze still re-arms regardless (DoseActions.snooze).
|
Notifications.doseNotificationId(log.id),
|
||||||
if (repeatReminders(context) && log.nagCount < AlarmScheduler.NAG_CAP) {
|
Notifications.buildDoseNotification(context, med, log),
|
||||||
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,36 +98,15 @@ 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)
|
||||||
|
|
||||||
// Re-post the whole occurrence so co-timed siblings stay on one card.
|
Notifications.post(
|
||||||
postOccurrence(context, db, log.scheduledAtMillis)
|
context,
|
||||||
// Re-arm the next automatic nag only while repeats are enabled. A nag
|
Notifications.doseNotificationId(logId),
|
||||||
// already in flight from a Snooze still fires once (this post), then
|
Notifications.buildDoseNotification(context, med, nagged),
|
||||||
// stops here — Snooze stays a single deferral, not an endless chain.
|
)
|
||||||
if (repeatReminders(context) && nagged.nagCount < AlarmScheduler.NAG_CAP) {
|
if (nagged.nagCount < AlarmScheduler.NAG_CAP) AlarmScheduler(context).armEscalation(logId)
|
||||||
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,22 +22,6 @@ 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,44 +35,27 @@ 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
|
||||||
takeState(log)
|
if (log.status == DoseStatus.TAKEN) return // double-tap must not decrement twice
|
||||||
refreshOccurrence(log.scheduledAtMillis)
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun snooze(logId: Long) {
|
suspend fun snooze(logId: Long) {
|
||||||
val log = db.doseLogDao().getById(logId) ?: return
|
db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis())
|
||||||
snoozeState(log)
|
scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES)
|
||||||
// Snooze dismisses now; the re-nag brings the card back in 15 min.
|
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
||||||
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)
|
||||||
refreshOccurrence(log.scheduledAtMillis)
|
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. */
|
/** Undo a mis-tap: back to PENDING, restoring stock if it was TAKEN. */
|
||||||
|
|
@ -89,62 +72,4 @@ 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,14 +23,8 @@ 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. */
|
||||||
* Stable per-OCCURRENCE notification id (minute resolution): every med due
|
fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt()
|
||||||
* 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
|
||||||
|
|
@ -65,67 +59,37 @@ object Notifications {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dose reminder card for one occurrence. [entries] are all the meds due
|
* The dose reminder card. nagCount > 0 marks a re-nag — same id, so it
|
||||||
* at the same wall-clock minute (one entry = a normal single card; several =
|
* replaces the previous card but re-alerts (no setOnlyAlertOnce: re-alerting
|
||||||
* a grouped card with "alle" actions). Actions carry the scheduledAtMillis,
|
* is the escalation).
|
||||||
* 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(
|
fun buildDoseNotification(context: Context, med: MedWithItem, log: DoseLog): Notification {
|
||||||
context: Context,
|
val scheduled = Instant.ofEpochMilli(log.scheduledAtMillis).atZone(ZoneId.systemDefault())
|
||||||
entries: List<Pair<MedWithItem, DoseLog>>,
|
val title = med.displayName
|
||||||
scheduledAtMillis: Long,
|
val body = buildString {
|
||||||
alertAgain: Boolean = true,
|
append("${formatAmount(log.amount)} ${med.item.unit} kl. ${timeFmt.format(scheduled)}")
|
||||||
): Notification {
|
if (med.med.withFood) append(" — tas med mat")
|
||||||
require(entries.isNotEmpty()) { "Dose notification needs at least one med" }
|
if (log.nagCount > 0) append(" (påminnelse ${log.nagCount})")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val title: String
|
fun action(action: String): PendingIntent = PendingIntent.getBroadcast(
|
||||||
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,
|
||||||
// One requestCode per occurrence; the Intent action keeps the
|
// Distinct requestCode per (log, action); same (log, action) replaces.
|
||||||
// Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals).
|
(log.id * 10 + action.hashCode().mod(10)).toInt(),
|
||||||
doseNotificationId(scheduledAtMillis),
|
|
||||||
Intent(context, DoseActionReceiver::class.java)
|
Intent(context, DoseActionReceiver::class.java)
|
||||||
.setAction(actionStr)
|
.setAction(action)
|
||||||
.putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis),
|
.putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id),
|
||||||
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(scheduledAtMillis),
|
doseNotificationId(log.id),
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(context, CHANNEL_DOSES)
|
return NotificationCompat.Builder(context, CHANNEL_DOSES)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
|
|
@ -134,22 +98,9 @@ object Notifications {
|
||||||
.setContentIntent(contentIntent)
|
.setContentIntent(contentIntent)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setOngoing(false)
|
.setOngoing(false)
|
||||||
.setOnlyAlertOnce(!alertAgain)
|
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
|
||||||
.addAction(0, if (grouped) "Tatt alle" else "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
|
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
|
||||||
.addAction(0, if (grouped) "Utsett alle" else "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
|
.build()
|
||||||
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,17 +55,6 @@ 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,7 +21,6 @@ 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
|
||||||
|
|
@ -118,7 +117,6 @@ 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()
|
||||||
|
|
@ -155,33 +153,6 @@ 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