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

@ -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(