feat(todo): reminders on the device, Upcoming's week strip, the task pane and search (ADR 0033, D2, D5, D8)

A task with a time takes reminders, minutes before it, in its sealed
payload; each device plans the coming ones whenever the tasks change and
schedules them with the system through flutter_local_notifications
(Android, iOS, macOS, Windows; on Linux the running app shows them), at
the minute where Android allows exact alarms. Notifying is asked for
when a task first gets a reminder; signing out or switching person
cancels them. Upcoming gets its week strip: the month opening
Material's calendar, the weeks before and after, a dot under each day
with tasks; a day picked scrolls the agenda to it, and scrolling moves
the strip along. From expanded windows on, a task opens in a pane
beside the list rather than in a dialog. Search, from every tab's app
bar, finds projects by name and tasks by every word typed, in their
title, description or labels, whatever the case and accents, on the
device alone.

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 12:16:19 +01:00
commit 666e8dbcbb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,110 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/model/reminder_plan.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// Which reminders a device schedules (ADR 0033, D5): each open task's,
/// before its time, from now on, the nearest first, as many as the system
/// keeps.
void main() {
final now = DateTime(2026, 11, 2, 8);
const today = Day(2026, 11, 2);
StoredTask task(
String id, {
TaskDate? date,
List<int> reminders = const [30],
DateTime? completedAt,
}) => StoredTask(
id,
TaskContent(
title: 'Task $id',
projectId: 'p',
date: date,
reminders: reminders,
completedAt: completedAt,
),
);
test('each reminder of an open task with a time, before it, the nearest '
'first', () {
final plan = planReminders([
task(
'a',
date: const TaskDate(today, time: 10 * 60),
reminders: [0, 30],
),
task('b', date: const TaskDate(Day(2026, 11, 3), time: 9 * 60)),
], now: now);
expect(
[for (final r in plan) (r.taskId, r.at, r.minutesBefore)],
[
('a', DateTime(2026, 11, 2, 9, 30), 30),
('a', DateTime(2026, 11, 2, 10), 0),
('b', DateTime(2026, 11, 3, 8, 30), 30),
],
);
expect(plan.first.title, 'Task a');
expect(plan.first.time, 10 * 60);
});
test('none for a task done, without a time, past, locked, or too far '
'ahead', () {
final plan = planReminders([
task(
'done',
date: const TaskDate(today, time: 10 * 60),
completedAt: now,
),
task('day only', date: const TaskDate(today)),
task('undated'),
task('past', date: const TaskDate(today, time: 8 * 60)),
const StoredTask('locked', null),
task('far', date: const TaskDate(Day(2027, 3, 1), time: 9 * 60)),
task('soon', date: const TaskDate(today, time: 8 * 60 + 10)),
], now: now);
expect([for (final r in plan) r.taskId], isEmpty);
});
test('a recurring task reminds before its next time alone', () {
final plan = planReminders([
task(
'daily',
date: const TaskDate(
today,
time: 9 * 60,
repeat: Repeat(RepeatUnit.day),
),
reminders: [0],
),
], now: now);
expect([for (final r in plan) r.at], [DateTime(2026, 11, 2, 9)]);
});
test('no more than the limit, the nearest kept', () {
final plan = planReminders(
[
for (var i = 1; i <= 5; i++)
task('$i', date: TaskDate(today.plus(i), time: 9 * 60)),
],
now: now,
limit: 3,
);
expect([for (final r in plan) r.taskId], ['1', '2', '3']);
});
test("a reminder's ID is the same on every plan, and differs between a "
"task's reminders and between tasks", () {
PlannedReminder only(String id, int before) => planReminders([
task(
id,
date: const TaskDate(today, time: 12 * 60),
reminders: [before],
),
], now: now).single;
expect(only('a', 30).id, only('a', 30).id);
expect(only('a', 30).id, isNot(only('a', 0).id));
expect(only('a', 30).id, isNot(only('b', 30).id));
expect(only('a', 30).id, inInclusiveRange(0, 0x7fffffff));
});
}