Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
source link hands off to the browser with an intent, so release builds still need no
Expand Down Expand Up @@ -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":
Expand Down
19 changes: 11 additions & 8 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class AlarmActivity : Activity() {

private var doses: List<AlarmService.Due> = 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()
Expand Down Expand Up @@ -69,8 +72,11 @@ class AlarmActivity : Activity() {
findViewById<android.widget.Button>(R.id.alarm_taken).setOnClickListener {
finishWith(PendingAction.TAKEN)
}
findViewById<android.widget.Button>(R.id.alarm_snooze).setOnClickListener {
finishWith(PendingAction.SNOOZE)
findViewById<android.widget.Button>(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) }
}
}

Expand Down Expand Up @@ -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()
Expand All @@ -130,8 +137,4 @@ class AlarmActivity : Activity() {
}

enum class PendingAction { TAKEN, SNOOZE }

companion object {
private const val SNOOZE_MS = 10 * 60 * 1000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Number>("minutes") ?: 10).toInt())
result.success(SnoozePrefs.snoozeMinutes(this))
}

"openUrl" -> {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(call.argument<String>("url"))))
Expand Down
34 changes: 34 additions & 0 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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.
*
* 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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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)
}
13 changes: 13 additions & 0 deletions lib/services/alarm_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ class AlarmBridge {
static Future<String> appVersion() async =>
await _channel.invokeMethod<String>('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<int> getSnoozeMinutes() async =>
await _channel.invokeMethod<int>('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<void> 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<bool> openUrl(String url) async =>
Expand Down
27 changes: 27 additions & 0 deletions lib/ui/info_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class InfoPage extends StatefulWidget {

class _InfoPageState extends State<InfoPage> {
final _version = AlarmBridge.appVersion();
var _snoozeMinutes = AlarmBridge.getSnoozeMinutes();
var _versionTaps = 0;

/// Android makes you a developer after tapping the build number. Here, nobody came.
Expand Down Expand Up @@ -61,6 +62,32 @@ class _InfoPageState extends State<InfoPage> {
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<int>(
future: _snoozeMinutes,
builder: (_, snapshot) => PopupMenuButton<int>(
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'),
Expand Down
Loading