From 7c9728695095f532b0b0990a2ef57cb9483d0be5 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:15:44 +0100 Subject: [PATCH 1/2] Let the user choose the snooze length Snooze was fixed at 10 minutes. The Info page now offers 5, 10, 15 or 30 minutes (default 10). The value lives in a small native preferences file because the alarm screen snoozes while Dart may not be running, and the alarm's Snooze button shows the chosen length. It's a device preference, so it stays out of the backup format. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 8 ++++- .../dev/susiee/dosekeeper/AlarmActivity.kt | 19 ++++++----- .../dev/susiee/dosekeeper/MainActivity.kt | 8 +++++ .../dev/susiee/dosekeeper/SnoozePrefs.kt | 33 +++++++++++++++++++ lib/services/alarm_bridge.dart | 13 ++++++++ lib/ui/info_page.dart | 27 +++++++++++++++ 6 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt diff --git a/AGENTS.md b/AGENTS.md index 1f34c19..707f03f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -340,6 +340,13 @@ the user's phone. `master` is branch-protected — see "Git workflow" below. ``` Also check that the Settings button lands on a sensible screen on ColorOS. +- **Adjustable snooze length** (`SnoozePrefs.kt`, Info page "Snooze length") — the alarm + screen's Snooze waits 5/10/15/30 minutes (default 10, chosen on the Info page). Stored + in its own native SharedPreferences file (`dosekeeper_settings`), not Dart or the DB, + because `AlarmActivity` snoozes while Dart may not be running; unknown values fall back + to 10 there. The alarm button label ("SNOOZE N MIN") shows the chosen length. Not in + the backup format on purpose: it's a device preference, not dose data. + - **Info page** (`lib/ui/info_page.dart`, app-bar info icon) -- version as Android reports it, so it matches App info. Debug builds get a `-debug` suffix (`versionNameSuffix`). The source link hands off to the browser with an intent, so release builds still need no @@ -405,7 +412,6 @@ Quality of life: - **"As-needed" (PRN) medications** — no fixed schedule, just a manual log button for ad hoc doses. Common for real regimens (e.g. pain relief) that the current fixed-time/fixed-weekday model can't represent at all. -- **Adjustable snooze length** — hardcoded to 10 minutes in `AlarmActivity.SNOOZE_MS`. - **Home-screen widget** showing the next dose at a glance. Data ownership — fits "no account, no server": diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt index 2122406..4e6c711 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt @@ -19,6 +19,9 @@ class AlarmActivity : Activity() { private var doses: List = emptyList() + /** Set in onCreate from SnoozePrefs; the label and the snooze use the same value. */ + private var snoozeMinutes = 10 + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) showOverLockscreen() @@ -69,8 +72,11 @@ class AlarmActivity : Activity() { findViewById(R.id.alarm_taken).setOnClickListener { finishWith(PendingAction.TAKEN) } - findViewById(R.id.alarm_snooze).setOnClickListener { - finishWith(PendingAction.SNOOZE) + findViewById(R.id.alarm_snooze).apply { + // Read here, once, so the label and the actual snooze length can't disagree. + snoozeMinutes = SnoozePrefs.snoozeMinutes(this@AlarmActivity) + text = "SNOOZE $snoozeMinutes MIN" + setOnClickListener { finishWith(PendingAction.SNOOZE) } } } @@ -99,12 +105,13 @@ class AlarmActivity : Activity() { private fun finishWith(action: PendingAction) { val now = System.currentTimeMillis() if (action == PendingAction.SNOOZE) { + val snoozeMs = snoozeMinutes * 60 * 1000L val snoozed = doses.filter { - AlarmScheduler.schedule(this, it.id, now + SNOOZE_MS, it.name, it.dosage) + AlarmScheduler.schedule(this, it.id, now + snoozeMs, it.name, it.dosage) } // Android refused a snooze alarm. Stopping now would mean that dose never rings // again, so keep ringing and say so. Undo the snoozes that did arm: tapping Taken - // next would otherwise ring them again in 10 minutes. + // next would otherwise ring them again minutes from now. if (snoozed.size < doses.size) { snoozed.forEach { AlarmScheduler.disarm(this, it.id) } Toast.makeText(this, "Couldn't snooze. Take it now, or try again.", Toast.LENGTH_LONG).show() @@ -130,8 +137,4 @@ class AlarmActivity : Activity() { } enum class PendingAction { TAKEN, SNOOZE } - - companion object { - private const val SNOOZE_MS = 10 * 60 * 1000L - } } diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt index 6fb6d2c..776014e 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt @@ -214,6 +214,14 @@ class MainActivity : FlutterActivity() { result.success(packageManager.getPackageInfo(packageName, 0).versionName) } + /** The snooze length in minutes; native, so it works while Dart isn't running. */ + "getSnoozeMinutes" -> result.success(SnoozePrefs.snoozeMinutes(this)) + + "setSnoozeMinutes" -> { + SnoozePrefs.setSnoozeMinutes(this, (call.argument("minutes") ?: 10).toInt()) + result.success(SnoozePrefs.snoozeMinutes(this)) + } + "openUrl" -> { try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(call.argument("url")))) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt new file mode 100644 index 0000000..76a6bc9 --- /dev/null +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt @@ -0,0 +1,33 @@ +package dev.susiee.dosekeeper + +import android.content.Context + +/** + * The user's snooze length, in minutes. Stored natively (its own SharedPreferences file) + * because the alarm screen snoozes while the Dart engine may not be running at all -- + * Dart only ever reads/writes it through the MethodChannel for the settings UI. + * + * A device preference, deliberately NOT in the JSON backup: it isn't dose data, and a + * restore onto another phone shouldn't drag settings along. + */ +object SnoozePrefs { + private const val PREFS = "dosekeeper_settings" + private const val KEY_SNOOZE_MINUTES = "snooze_minutes" + + val ALLOWED = setOf(5, 10, 15, 30) + + /** Missing/corrupt/unknown value falls back to 10, the historical hardcoded length. */ + fun snoozeMinutes(context: Context): Int { + val v = prefs(context).getInt(KEY_SNOOZE_MINUTES, 10) + return if (v in ALLOWED) v else 10 + } + + fun setSnoozeMinutes(context: Context, minutes: Int) { + // Only the allowed choices are ever written, so the fallback above stays a fallback. + if (minutes !in ALLOWED) return + prefs(context).edit().putInt(KEY_SNOOZE_MINUTES, minutes).apply() + } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} \ No newline at end of file diff --git a/lib/services/alarm_bridge.dart b/lib/services/alarm_bridge.dart index 42e0035..9f94a5e 100644 --- a/lib/services/alarm_bridge.dart +++ b/lib/services/alarm_bridge.dart @@ -86,6 +86,19 @@ class AlarmBridge { static Future appVersion() async => await _channel.invokeMethod('appVersion') ?? 'Unknown'; + /// Snooze length in minutes. Stored natively because the native alarm screen + /// snoozes while Dart may not be running; unknown values fall back to 10 there. + static Future getSnoozeMinutes() async => + await _channel.invokeMethod('getSnoozeMinutes') ?? 10; + + /// The lengths the user can pick; must match SnoozePrefs.ALLOWED natively. + static const snoozeChoices = [5, 10, 15, 30]; + + /// Sets the snooze length in minutes. Anything outside [snoozeChoices] is + /// ignored natively and leaves the previous value alone. + static Future setSnoozeMinutes(int minutes) => + _channel.invokeMethod('setSnoozeMinutes', {'minutes': minutes}); + /// Opens [url] in the browser. The app itself still makes no network requests. /// False if nothing on the phone can open it. static Future openUrl(String url) async => diff --git a/lib/ui/info_page.dart b/lib/ui/info_page.dart index 066c8b6..f663264 100644 --- a/lib/ui/info_page.dart +++ b/lib/ui/info_page.dart @@ -22,6 +22,7 @@ class InfoPage extends StatefulWidget { class _InfoPageState extends State { final _version = AlarmBridge.appVersion(); + var _snoozeMinutes = AlarmBridge.getSnoozeMinutes(); var _versionTaps = 0; /// Android makes you a developer after tapping the build number. Here, nobody came. @@ -61,6 +62,32 @@ class _InfoPageState extends State { trailing: const Icon(Icons.open_in_new), onTap: _openSource, ), + // How long the alarm screen's Snooze waits before ringing again. + // Native-backed (see AlarmBridge.getSnoozeMinutes): the alarm screen + // snoozes without Dart running. + ListTile( + title: const Text('Snooze length'), + subtitle: const Text('How long the alarm waits before ringing again'), + trailing: FutureBuilder( + future: _snoozeMinutes, + builder: (_, snapshot) => PopupMenuButton( + initialValue: snapshot.data, + tooltip: 'Snooze length', + onSelected: (minutes) async { + await AlarmBridge.setSnoozeMinutes(minutes); + if (!mounted) return; + setState(() => _snoozeMinutes = AlarmBridge.getSnoozeMinutes()); + }, + itemBuilder: (_) => [ + for (final m in AlarmBridge.snoozeChoices) + PopupMenuItem(value: m, child: Text('$m min')), + ], + child: Text( + snapshot.data == null ? '…' : '${snapshot.data} min', + ), + ), + ), + ), const ListTile(title: Text('License'), subtitle: Text('MIT')), const ListTile( title: Text('Your data'), From 2e2f91572b60192d5182f7816e0bdf531ed0edc6 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:22:09 +0100 Subject: [PATCH 2/2] Say plainly which backups carry the snooze length Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 707f03f..0db683f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,7 +345,7 @@ the user's phone. `master` is branch-protected — see "Git workflow" below. in its own native SharedPreferences file (`dosekeeper_settings`), not Dart or the DB, because `AlarmActivity` snoozes while Dart may not be running; unknown values fall back to 10 there. The alarm button label ("SNOOZE N MIN") shows the chosen length. Not in - the backup format on purpose: it's a device preference, not dose data. + the JSON folder backup (dose data only); Google backup carries it like any preference. - **Info page** (`lib/ui/info_page.dart`, app-bar info icon) -- version as Android reports it, so it matches App info. Debug builds get a `-debug` suffix (`versionNameSuffix`). The diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt index 76a6bc9..a89fdd3 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt @@ -7,8 +7,9 @@ import android.content.Context * because the alarm screen snoozes while the Dart engine may not be running at all -- * Dart only ever reads/writes it through the MethodChannel for the settings UI. * - * A device preference, deliberately NOT in the JSON backup: it isn't dose data, and a - * restore onto another phone shouldn't drag settings along. + * Not in the JSON folder backup: that holds medications and dose history only. Google + * backup (opt-in) does carry it, like every other preference, which is fine: a setting + * following the user to a new phone is what they'd expect. */ object SnoozePrefs { private const val PREFS = "dosekeeper_settings"