feat(todo): Settings' Tasks and Reminders, with Snooze on a reminder (ADR 0033, D4)

Settings gains Todo's own groups, kept in the person's sealed settings
row on every device. Tasks: the weekday weeks start on (the language's
unless set), which the week strip, the date and repeat pickers and the
filters follow; and whether a recurring task done opens its sub-tasks
again (on at first). Reminders: an automatic reminder (none at first)
that TaskRepository writes into a task gaining a time without reminders
of its own, and how long a reminder is snoozed for (15 minutes at first).

Each reminder's notification has a Snooze action on Android and Linux:
the reminder comes back that much later under an ID of its own,
scheduled from what the notification carries, so on Android with the
app closed too (the plugin's ActionBroadcastReceiver declared); the plan
of the person's reminders leaves snoozed ones alone. iOS, macOS and
Windows get it later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-01 20:25:15 +01:00
commit 51ebed41cd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 1204 additions and 69 deletions

View file

@ -17,6 +17,7 @@ import 'package:halkyon_todo_flutter/src/feature/project/widget/project_calendar
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
@ -144,6 +145,7 @@ void main() {
openPerson: (person) async {
opened.add(person);
final projects = ProjectRepository(projectRows, keys);
final settings = SettingsRepository(settingsRows, keys);
// As the app does when it opens the person's data.
await projects.ensureInbox();
return TodoPerson(
@ -151,9 +153,14 @@ void main() {
sync: sync,
projects: projects,
sections: SectionRepository(sectionRows, keys),
tasks: TaskRepository(taskRows, keys),
tasks: TaskRepository(
taskRows,
keys,
automaticReminder: () async =>
(await settings.read()).reminders.automatic,
),
labels: LabelRepository(labelRows, keys),
settings: SettingsRepository(settingsRows, keys),
settings: settings,
reminders: reminders,
);
},
@ -197,6 +204,15 @@ void main() {
await tester.pumpAndSettle();
}
/// Taps [text] in Settings, scrolled into view: the account is near the
/// foot, below Todo's own groups.
Future<void> tapInSettings(WidgetTester tester, String text) async {
await tester.ensureVisible(find.text(text).first);
await tester.pumpAndSettle();
await tester.tap(find.text(text).first);
await tester.pumpAndSettle();
}
testWidgets("a new install chooses its instance first, in Todo's words and "
'look', (tester) async {
await start(tester);
@ -2073,6 +2089,87 @@ void main() {
expect(top(find.text('Haircut')), lessThan(600), reason: 'scrolled to');
},
);
group('Settings of tasks and reminders (D4)', () {
Future<void> choose(
WidgetTester tester,
String setting,
String value,
) async {
await tester.tap(find.text(setting));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, value).last);
await tester.pumpAndSettle();
}
Future<TodoSettings> stored(WidgetTester tester) async =>
(await tester.runAsync(
() => SettingsRepository(settingsRows, keys).read(),
))!;
testWidgets('Tasks and Reminders come before the account; a week '
"started on Sunday starts Upcoming's strip on it, kept for every "
'device', (tester) async {
await start(tester, usedBefore: true);
await openSettings(tester);
final order = [
'Sync',
'Tasks',
'Reminders',
'Account',
].map((text) => tester.getTopLeft(find.text(text)).dy).toList();
expect(order, [...order]..sort());
expect(find.text('As the language has it'), findsOneWidget);
await choose(tester, 'Start the week on', 'Sunday');
expect((await stored(tester)).tasks.weekStart, DateTime.sunday);
expect(find.text('Sunday'), findsOneWidget);
await tester.tap(find.text('Reset sub-tasks'));
await tester.pumpAndSettle();
expect((await stored(tester)).tasks.resetSubTasks, isFalse);
await tester.pageBack();
await tester.pumpAndSettle();
await goTab(tester, 'Upcoming');
final sunday = WeekStrip.weekOf(Day.of(DateTime.now()), 0);
expect(find.byKey(WeekStrip.dayKey(sunday)), findsOneWidget);
expect(
find.byKey(WeekStrip.dayKey(sunday.plus(7))),
findsNothing,
reason: 'the week ends on Saturday',
);
});
testWidgets('a task added with a time gets the automatic reminder; '
'reminders are snoozed for as long as set', (tester) async {
await start(tester, usedBefore: true);
await openSettings(tester);
expect(find.textContaining(RegExp(r'^None\n')), findsOneWidget);
await choose(tester, 'Automatic reminder', '30 minutes before');
await choose(tester, 'When snoozed', '10 minutes');
final settings = await stored(tester);
expect(settings.reminders.automatic, 30);
expect(settings.reminders.snooze, 10);
await tester.pageBack();
await tester.pumpAndSettle();
await goTab(tester, 'Inbox');
await quickAdd(tester, 'Call tomorrow 9am');
await quickAdd(tester, 'Shop tomorrow');
final all = await tester.runAsync(() => tasks().all());
final kept = {
for (final task in all!) task.content!.title: task.content!.reminders,
};
expect(kept, {
'Call': [30],
'Shop': <int>[],
});
expect(
reminders.snooze,
(minutes: 10, label: 'Snooze 10 min'),
);
});
});
});
group("Upcoming's strip (ADR 0033, D8, D14)", () {
@ -2173,8 +2270,7 @@ void main() {
expect(find.text('Signed in on todo.example.org'), findsOneWidget);
expect(find.text('Switch account'), findsOneWidget);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
await tapInSettings(tester, 'Sign out');
expect(gateway.signOuts, 1);
expect(gateway.accountDeletions, 0);
@ -2199,15 +2295,13 @@ void main() {
await start(tester, usedBefore: true);
await openSettings(tester);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tapInSettings(tester, 'Delete account');
expect(find.text('Delete your account?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 0);
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tapInSettings(tester, 'Delete account');
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
@ -2221,8 +2315,7 @@ void main() {
await openSettings(tester);
gateway.deletionError = Exception('No connection');
await tester.tap(find.text('Delete account'));
await tester.pumpAndSettle();
await tapInSettings(tester, 'Delete account');
await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle();
@ -2242,8 +2335,7 @@ void main() {
await start(tester, usedBefore: true);
await openSettings(tester);
await tester.tap(find.text('About Halkyon Todo'));
await tester.pumpAndSettle();
await tapInSettings(tester, 'About Halkyon Todo');
expect(find.text('The source of what this instance runs:'), findsOneWidget);
expect(

View file

@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/model/snooze.dart';
/// A reminder snoozed (ADR 0033, D4): what its notification carries, so
/// that the system can bring it back with the app closed, and what comes
/// back.
void main() {
const reminder = (
id: 41,
at: null,
title: 'Feed the dogs',
body: 'Due at 06:00',
);
const snooze = (minutes: 10, label: 'Snooze 10 min', channel: 'Reminders');
final now = DateTime.utc(2026, 11, 2, 5, 30);
test('comes back as the same reminder, the snooze later, under an ID of '
'its own, snoozable again', () {
final payload = snoozePayload(
title: reminder.title,
body: reminder.body,
snooze: snooze,
);
final back = snoozed(id: reminder.id, payload: payload, now: now)!;
expect(back.notice.at, now.add(const Duration(minutes: 10)));
expect(back.notice.title, 'Feed the dogs');
expect(back.notice.body, 'Due at 06:00');
expect(back.notice.id, isNot(reminder.id));
expect(back.snooze, snooze);
expect(isSnoozed(back.payload), isTrue);
expect(isSnoozed(payload), isFalse);
final again = snoozed(id: back.notice.id, payload: back.payload, now: now);
expect(again!.notice.id, back.notice.id, reason: 'replaced, not doubled');
});
test('a payload not of a snoozable reminder brings nothing back', () {
for (final payload in [null, '', 'x', '{"t":"a"}', '[1]']) {
expect(snoozed(id: 1, payload: payload, now: now), isNull);
expect(isSnoozed(payload), isFalse);
}
});
}

View file

@ -68,4 +68,48 @@ void main() {
);
expect(decodeSettings(payload({'schemaVersion': 2})), isNull);
});
test("D4's settings of tasks and reminders read back as they were set", () {
const settings = TodoSettings(
tasks: TaskSettings(weekStart: DateTime.sunday, resetSubTasks: false),
reminders: ReminderSettings(automatic: 30, snooze: 10),
);
expect(decodeSettings(encodeSettings(settings)), settings);
expect(written(settings), {
'schemaVersion': 1,
'tasks': {'weekStart': 7, 'resetSubTasks': false},
'reminders': {'automatic': 30, 'snooze': 10},
});
expect(
decodeSettings(
encodeSettings(
const TodoSettings(reminders: ReminderSettings(automatic: 0)),
),
),
const TodoSettings(reminders: ReminderSettings(automatic: 0)),
reason: 'at the time itself is a reminder too',
);
});
test('at first: the week from the language, sub-tasks reset, no automatic '
'reminder, snoozed for 15 minutes', () {
const settings = TodoSettings();
expect(settings.tasks.weekStart, isNull);
expect(settings.tasks.resetSubTasks, isTrue);
expect(settings.reminders.automatic, isNull);
expect(settings.reminders.snooze, 15);
});
test('values out of range read as at first', () {
expect(
decodeSettings(
payload({
'schemaVersion': 1,
'tasks': {'weekStart': 9, 'resetSubTasks': 'yes'},
'reminders': {'automatic': -5, 'snooze': 0},
}),
),
const TodoSettings(),
);
});
}

View file

@ -134,6 +134,93 @@ void main() {
);
});
test('a recurring task done leaves its sub-tasks done where the person '
'turned resetting them off (D4)', () async {
final phone = await device(alice);
final weekly = milk.copyWith(
date: () => milk.date!.repeating(const Repeat(RepeatUnit.week)),
);
final id = await phone.add(weekly);
final doneAt = DateTime(2026, 10, 30);
final subId = await phone.add(
TaskContent(
title: 'Oat',
projectId: 'inbox',
parentId: id,
completedAt: doneAt,
),
);
final before = await withClock(
Clock.fixed(DateTime(2026, 11, 2, 9, 30)),
() => phone.complete(StoredTask(id, weekly), reopenSubTasks: false),
);
final after = {for (final t in await phone.all()) t.id: t.content};
expect(after[id]?.date?.day, const Day(2026, 11, 9));
expect(after[subId]?.completedAt, doneAt);
expect([for (final t in before) t.id], [id]);
});
group('the automatic reminder (D4)', () {
Future<TaskRepository> reminding(int? minutes) async => TaskRepository(
rows,
await keysOf(alice),
automaticReminder: () async => minutes,
);
const untimed = TaskContent(
title: 'Call',
projectId: 'inbox',
date: TaskDate(Day(2026, 11, 2)),
);
test(
'a task added with a time and no reminder of its own gets it',
() async {
final phone = await reminding(30);
final timed = await phone.add(milk);
final own = await phone.add(milk.copyWith(reminders: [10]));
final dayOnly = await phone.add(untimed);
final contents = {for (final t in await phone.all()) t.id: t.content};
expect(contents[timed]?.reminders, [30]);
expect(contents[own]?.reminders, [10]);
expect(contents[dayOnly]?.reminders, isEmpty);
},
);
test('a task given a time gets it; one that had a time keeps what the '
'person left it, none too', () async {
final phone = await reminding(0);
final id = await phone.add(untimed);
await phone.update(
id,
untimed.copyWith(
date: () => const TaskDate(Day(2026, 11, 2), time: 540),
),
);
var content = (await phone.all()).single.content!;
expect(content.reminders, [0]);
await phone.update(id, content.copyWith(reminders: const []));
await phone.update(
id,
content.copyWith(
reminders: const [],
date: () => const TaskDate(Day(2026, 11, 3), time: 600),
),
);
content = (await phone.all()).single.content!;
expect(content.reminders, isEmpty);
});
test('none set, nothing is added', () async {
final phone = await reminding(null);
await phone.add(milk);
expect((await phone.all()).single.content?.reminders, isEmpty);
});
});
test('a recurring task done its last time is done, its sub-tasks as they '
'were', () async {
final phone = await device(alice);

View file

@ -9,6 +9,9 @@ class MemoryReminderGateway() implements ReminderGateway {
/// How often the scheduled ones were replaced.
int replaced = 0;
/// How the scheduled ones are snoozed.
({int minutes, String label})? snooze;
/// What the person answers when asked.
bool allowed = true;
@ -19,8 +22,10 @@ class MemoryReminderGateway() implements ReminderGateway {
Future<void> replace(
List<ReminderNotice> notices, {
required String channel,
({int minutes, String label})? snooze,
}) async {
scheduled = [...notices];
this.snooze = snooze;
replaced++;
}