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
3 changed files with 2140 additions and 57 deletions

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

View file

@ -42,6 +42,7 @@ void main() {
deadline: const Day(2026, 11, 8),
priority: Priority.p2,
labelIds: const ['l1', 'l2'],
reminders: const [30, 0],
completedAt: DateTime(2026, 11, 2, 7),
completions: [DateTime(2026, 10, 19, 7, 5), DateTime(2026, 10, 22, 6)],
);
@ -77,6 +78,7 @@ void main() {
'deadline': '2026-11-08',
'priority': 2,
'labelIds': ['l1', 'l2'],
'reminders': [30, 0],
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
'completions': [
DateTime(2026, 10, 19, 7, 5).toUtc().toIso8601String(),

View file

@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_search.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// Search (ADR 0033, D2): the tasks every word typed is found in, open
/// ones first and the nearest date first, on the device alone, as the
/// instance reads no task (D5).
void main() {
OpenTask task(
String id,
String title, {
String description = '',
List<String> labels = const [],
Day? day,
bool done = false,
}) => (
id: id,
content: TaskContent(
title: title,
projectId: 'p',
description: description,
labelIds: labels,
date: day == null ? null : TaskDate(day),
completedAt: done ? DateTime(2026, 11, 2) : null,
),
);
final tasks = [
task('milk', 'Buy oat milk', labels: ['errand']),
task('call', 'Call Anna', description: 'About the Café opening'),
task('done', 'Buy bread', done: true),
task('later', 'Buy stamps', day: const Day(2026, 11, 20)),
task('soon', 'Buy flowers', day: const Day(2026, 11, 3)),
];
const labels = {'errand': 'Errands'};
List<String> found(String query) => [
for (final task in searchTasks(tasks, query, labelNames: labels)) task.id,
];
test('finds every word in the title, the description or a label, in any '
'case and with or without accents', () {
expect(found('anna cafe'), ['call']);
expect(found('CAFÉ'), ['call']);
expect(found('errands'), ['milk']);
expect(found('oat errand'), ['milk']);
expect(found('oat anna'), isEmpty);
});
test('puts the open ones first, the nearest date first, the undated after '
'them, the done ones last', () {
expect(found('buy'), ['soon', 'later', 'milk', 'done']);
});
test('finds nothing for nothing typed', () {
expect(found(''), isEmpty);
expect(found(' '), isEmpty);
});
test('folds what is typed as it folds what is searched', () {
expect(folded('Ärger über Öl, Straße'), 'arger uber ol, strasse');
});
}