Compare commits

..

No commits in common. "041f9e2cf68b63435f989c617f364d28846781e5" and "5b9a3d6877a28636c9d1ace9cb8d04e7d63fbd63" have entirely different histories.

10 changed files with 66 additions and 372 deletions

View file

@ -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"
} }

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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)
}
}
} }

View file

@ -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
} }

View file

@ -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>>

View file

@ -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))
}
} }

View file

@ -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. */

View file

@ -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() &&

View file

@ -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