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>
This commit is contained in:
parent
5b9a3d6877
commit
778a6aec27
7 changed files with 82 additions and 3 deletions
|
|
@ -90,7 +90,9 @@ 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.
|
// 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()
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import no.naiv.meddetsamme.domain.DoseActions
|
||||||
* - 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.
|
||||||
* - Skip → SKIPPED, stop escalating, no inventory change.
|
* - Skip → SKIPPED, stop escalating, no inventory change.
|
||||||
|
* - Mute → "Ikke forstyrr": stop escalating, dose stays unresolved.
|
||||||
*/
|
*/
|
||||||
class DoseActionReceiver : BroadcastReceiver() {
|
class DoseActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ class DoseActionReceiver : BroadcastReceiver() {
|
||||||
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_SKIP = "no.naiv.meddetsamme.action.SKIP"
|
const val ACTION_SKIP = "no.naiv.meddetsamme.action.SKIP"
|
||||||
|
const val ACTION_MUTE = "no.naiv.meddetsamme.action.MUTE"
|
||||||
const val EXTRA_LOG_ID = "logId"
|
const val EXTRA_LOG_ID = "logId"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +47,7 @@ class DoseActionReceiver : BroadcastReceiver() {
|
||||||
ACTION_TAKEN -> actions.take(logId)
|
ACTION_TAKEN -> actions.take(logId)
|
||||||
ACTION_SNOOZE -> actions.snooze(logId)
|
ACTION_SNOOZE -> actions.snooze(logId)
|
||||||
ACTION_SKIP -> actions.skip(logId)
|
ACTION_SKIP -> actions.skip(logId)
|
||||||
|
ACTION_MUTE -> actions.mute(logId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,11 @@ class DoseAlarmReceiver : BroadcastReceiver() {
|
||||||
Notifications.doseNotificationId(log.id),
|
Notifications.doseNotificationId(log.id),
|
||||||
Notifications.buildDoseNotification(context, med, log),
|
Notifications.buildDoseNotification(context, med, log),
|
||||||
)
|
)
|
||||||
if (log.nagCount < AlarmScheduler.NAG_CAP) scheduler.armEscalation(log.id)
|
// 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) {
|
private suspend fun escalate(context: Context, logId: Long) {
|
||||||
|
|
@ -107,6 +111,14 @@ class DoseAlarmReceiver : BroadcastReceiver() {
|
||||||
Notifications.doseNotificationId(logId),
|
Notifications.doseNotificationId(logId),
|
||||||
Notifications.buildDoseNotification(context, med, nagged),
|
Notifications.buildDoseNotification(context, med, nagged),
|
||||||
)
|
)
|
||||||
if (nagged.nagCount < AlarmScheduler.NAG_CAP) AlarmScheduler(context).armEscalation(logId)
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun repeatReminders(context: Context): Boolean =
|
||||||
|
no.naiv.meddetsamme.settings.SettingsStore(context).repeatReminders
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,21 @@ class DoseActions(private val context: Context) {
|
||||||
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Ikke forstyrr": stop nagging this dose but leave it unresolved — the user
|
||||||
|
* takes responsibility to remember it. Reuses the existing terminal state
|
||||||
|
* (nagCount at the cap) rather than a new flag, so escalation stops *and*
|
||||||
|
* armAll() won't resume it after a reboot (it re-arms only nagCount < cap).
|
||||||
|
* The dose stays PENDING, so it can still be taken from the app later.
|
||||||
|
*/
|
||||||
|
suspend fun mute(logId: Long) {
|
||||||
|
val log = db.doseLogDao().getById(logId) ?: return
|
||||||
|
if (log.status != DoseStatus.PENDING && log.status != DoseStatus.SNOOZED) return
|
||||||
|
db.doseLogDao().setNagCount(logId, AlarmScheduler.NAG_CAP)
|
||||||
|
scheduler.cancelEscalation(logId)
|
||||||
|
Notifications.cancel(context, Notifications.doseNotificationId(logId))
|
||||||
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
suspend fun undo(logId: Long) {
|
suspend fun undo(logId: Long) {
|
||||||
val log = db.doseLogDao().getById(logId) ?: return
|
val log = db.doseLogDao().getById(logId) ?: return
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,13 @@ object Notifications {
|
||||||
.setOngoing(false)
|
.setOngoing(false)
|
||||||
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
|
.addAction(0, "Tatt", action(DoseActionReceiver.ACTION_TAKEN))
|
||||||
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
|
.addAction(0, "Utsett 15 min", action(DoseActionReceiver.ACTION_SNOOZE))
|
||||||
|
// From the second reminder on, offer an escape hatch: stop nagging
|
||||||
|
// this dose without marking it skipped (the user takes over).
|
||||||
|
.apply {
|
||||||
|
if (log.nagCount >= 1) {
|
||||||
|
addAction(0, "Ikke forstyrr", action(DoseActionReceiver.ACTION_MUTE))
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,17 @@ 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,6 +21,7 @@ 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
|
||||||
|
|
@ -117,6 +118,7 @@ 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()
|
||||||
|
|
@ -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. */
|
/** 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