Compare commits

...

3 commits

Author SHA1 Message Date
041f9e2cf6 Release 0.6.0: bump versionCode 6 / versionName 0.6.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:43:13 +02:00
44ea4ed87b Reminders: one grouped card for co-timed meds, acked together
Meds due at the same wall-clock minute now share a single notification
instead of one card each — they resolve to an identical scheduledAtMillis,
which becomes the occurrence (and notification) key. Per the feature
request: "Tatt alle" / "Utsett alle" handle the whole morning batch in
one tap; a single med just renders as a normal card with "Tatt".

- Notification id is per-occurrence (scheduledAtMillis), not per-log, so
  co-timed doses collapse onto one card and re-nags replace it.
- DoseActionReceiver buttons carry the occurrence and call group ops
  (takeAll/snoozeAll/muteAll); DoseActions keeps a single per-dose state
  mutation (takeState/snoozeState/muteState) reused by both the group ops
  and the app's per-dose actions, so the two surfaces can't diverge.
- The card is rebuilt from getActiveAtOccurrence: still-unresolved doses
  with nagCount <= cap. "Ikke forstyrr" now sets nagCount above the cap so
  a muted dose drops off the card, while a naturally-capped dose (== cap)
  stays visible and actionable.
- refreshOccurrence re-renders silently after a single in-app action and
  never creates a card for a not-yet-due occurrence.

Adds DoseActionsGroupTest (instrumented) covering takeAll, muteAll, and
the muted-vs-capped distinction at the data layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:28:18 +02:00
778a6aec27 Reminders: global repeat toggle + per-dose "Ikke forstyrr"
Two ways to stop the every-10-min nag, per feature request:

- A global "Gjenta påminnelser" switch in Settings. Off → a due dose
  alerts once and goes quiet; only the automatic re-nag chain is gated,
  so an explicit "Utsett 15 min" still gives one more reminder. The gate
  sits at the three automatic arm points (fireOccurrence, escalate
  re-arm, armAll resume-after-reboot), never on Snooze.

- An "Ikke forstyrr" action that appears from the second reminder on
  (nagCount >= 1). It stops nagging this one dose but leaves it PENDING
  so it can still be taken later — distinct from Skip. Implemented by
  jumping to the existing terminal state (nagCount = NAG_CAP) rather than
  a new column, so it needs no migration and survives reboot for free
  (armAll re-arms only nagCount < cap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:16:16 +02:00
10 changed files with 372 additions and 66 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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