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

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);