diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cddc67a..b372eca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 = 6 - versionName = "0.6.0" + versionCode = 5 + versionName = "0.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt b/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt deleted file mode 100644 index d3df921..0000000 --- a/app/src/androidTest/java/no/naiv/meddetsamme/domain/DoseActionsGroupTest.kt +++ /dev/null @@ -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() - 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 { - 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) - } -} diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt index 08fc38a..bc485bc 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/AlarmScheduler.kt @@ -90,9 +90,7 @@ 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. 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 + // handles it when it actually falls due. val now = System.currentTimeMillis() db.doseLogDao().getUnresolved().forEach { log -> if (log.nagCount < NAG_CAP && log.scheduledAtMillis <= now) { diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt index 12c0495..91e3c5c 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseActionReceiver.kt @@ -9,41 +9,42 @@ import kotlinx.coroutines.launch import no.naiv.meddetsamme.domain.DoseActions /** - * 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". + * Handles the notification actions (and later, the same actions from the UI). * - * 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. * - Snooze → SNOOZED, next nag in 15 min, keeps escalating (and counting) after. - * - Mute → "Ikke forstyrr": stop escalating, dose stays unresolved. - * (Skip stays a per-dose UI action; the notification never offers it.) + * - Skip → SKIPPED, stop escalating, no inventory change. */ 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_MUTE = "no.naiv.meddetsamme.action.MUTE" - const val EXTRA_SCHEDULED_AT = "scheduledAt" + const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP" + const val EXTRA_LOG_ID = "logId" } override fun onReceive(context: Context, intent: Intent) { - val scheduledAt = intent.getLongExtra(EXTRA_SCHEDULED_AT, -1) - if (scheduledAt < 0) return + val logId = intent.getLongExtra(EXTRA_LOG_ID, -1) + if (logId < 0) return val action = intent.action ?: return val pending = goAsync() CoroutineScope(Dispatchers.IO).launch { try { - val actions = DoseActions(context) - when (action) { - ACTION_TAKEN -> actions.takeAll(scheduledAt) - ACTION_SNOOZE -> actions.snoozeAll(scheduledAt) - ACTION_MUTE -> actions.muteAll(scheduledAt) - } + handle(context, action, logId) } 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) + } + } } diff --git a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt index 17df536..9c0e79a 100644 --- a/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt +++ b/app/src/main/java/no/naiv/meddetsamme/alarm/DoseAlarmReceiver.kt @@ -80,12 +80,12 @@ class DoseAlarmReceiver : BroadcastReceiver() { else -> existing } - 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) - } + Notifications.post( + context, + Notifications.doseNotificationId(log.id), + Notifications.buildDoseNotification(context, med, log), + ) + if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id) } private suspend fun escalate(context: Context, logId: Long) { @@ -98,36 +98,15 @@ 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) - // 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) - } + Notifications.post( + context, + Notifications.doseNotificationId(logId), + Notifications.buildDoseNotification(context, med, nagged), + ) + if (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 } diff --git a/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt index 0d96035..b3adb77 100644 --- a/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt +++ b/app/src/main/java/no/naiv/meddetsamme/data/DoseLogDao.kt @@ -22,22 +22,6 @@ interface DoseLogDao { @Query("SELECT * FROM dose_log WHERE status IN ('PENDING', 'SNOOZED') ORDER BY scheduledAtMillis") suspend fun getUnresolved(): List - /** - * 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 - @Query("SELECT * FROM dose_log WHERE scheduledAtMillis >= :fromMillis AND scheduledAtMillis < :toMillis ORDER BY scheduledAtMillis") fun observeRange(fromMillis: Long, toMillis: Long): Flow> diff --git a/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt index bed4232..7afdc3d 100644 --- a/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt +++ b/app/src/main/java/no/naiv/meddetsamme/domain/DoseActions.kt @@ -35,44 +35,27 @@ 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 - takeState(log) - refreshOccurrence(log.scheduledAtMillis) + 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)) } suspend fun snooze(logId: Long) { - 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)) + db.doseLogDao().setStatus(logId, DoseStatus.SNOOZED, System.currentTimeMillis()) + scheduler.armEscalation(logId, delayMinutes = AlarmScheduler.SNOOZE_MINUTES) + Notifications.cancel(context, Notifications.doseNotificationId(logId)) } suspend fun skip(logId: Long) { - val log = db.doseLogDao().getById(logId) ?: return db.doseLogDao().setStatus(logId, DoseStatus.SKIPPED, System.currentTimeMillis()) scheduler.cancelEscalation(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) + Notifications.cancel(context, Notifications.doseNotificationId(logId)) } /** 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) 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 = - 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)) - } } diff --git a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt index 4561f9e..f478aa7 100644 --- a/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt +++ b/app/src/main/java/no/naiv/meddetsamme/notify/Notifications.kt @@ -23,14 +23,8 @@ object Notifications { const val CHANNEL_DOSES = "doses" const val CHANNEL_SUPPLY = "supply" - /** - * 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() + /** Stable per-occurrence notification id: every re-nag *replaces* the card. */ + fun doseNotificationId(logId: Long): Int = (logId % Int.MAX_VALUE).toInt() const val SUPPLY_NOTIFICATION_ID = 1_000_000_007 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 - * 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. + * 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). */ - fun buildDoseNotification( - context: Context, - entries: List>, - 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 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})") } - 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( + fun action(action: String): PendingIntent = PendingIntent.getBroadcast( context, - // One requestCode per occurrence; the Intent action keeps the - // Tatt/Utsett/Ikke-forstyrr PendingIntents distinct (filterEquals). - doseNotificationId(scheduledAtMillis), + // Distinct requestCode per (log, action); same (log, action) replaces. + (log.id * 10 + action.hashCode().mod(10)).toInt(), Intent(context, DoseActionReceiver::class.java) - .setAction(actionStr) - .putExtra(DoseActionReceiver.EXTRA_SCHEDULED_AT, scheduledAtMillis), + .setAction(action) + .putExtra(DoseActionReceiver.EXTRA_LOG_ID, log.id), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) val contentIntent = PendingIntent.getActivity( context, - doseNotificationId(scheduledAtMillis), + doseNotificationId(log.id), Intent(context, MainActivity::class.java), 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) .setContentTitle(title) .setContentText(body) @@ -134,22 +98,9 @@ object Notifications { .setContentIntent(contentIntent) .setAutoCancel(false) .setOngoing(false) - .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() + .addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN)) + .addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE)) + .build() } /** Morning low-stock digest: plainly dismissable, re-posted daily while low. */ diff --git a/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt index ae1cb1e..a88f8dc 100644 --- a/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt +++ b/app/src/main/java/no/naiv/meddetsamme/settings/SettingsStore.kt @@ -55,17 +55,6 @@ 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() && diff --git a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt index 1d9dcb8..3405dd8 100644 --- a/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt +++ b/app/src/main/java/no/naiv/meddetsamme/ui/SettingsScreen.kt @@ -21,7 +21,6 @@ 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 @@ -118,7 +117,6 @@ fun SettingsScreen(nav: Nav) { HorizontalDivider() Text("Varsler", style = MaterialTheme.typography.titleMedium) - RepeatRemindersRow(settings) SupplyCheckTimeRow(settings) 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. */ @OptIn(ExperimentalMaterial3Api::class) @Composable