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:
parent
aa013f5a00
commit
666e8dbcbb
5 changed files with 2140 additions and 57 deletions
|
|
@ -10,11 +10,13 @@ import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository
|
|||
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';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
import '../support/memory_reminder_gateway.dart';
|
||||
import '../support/memory_sealed_row_gateway.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -29,6 +31,7 @@ void main() {
|
|||
late MemorySealedRowGateway sectionRows;
|
||||
late MemorySealedRowGateway taskRows;
|
||||
late MemorySealedRowGateway labelRows;
|
||||
late MemoryReminderGateway reminders;
|
||||
|
||||
tearDown(() async {
|
||||
await sync.dispose();
|
||||
|
|
@ -51,6 +54,7 @@ void main() {
|
|||
sectionRows = MemorySealedRowGateway();
|
||||
taskRows = MemorySealedRowGateway();
|
||||
labelRows = MemorySealedRowGateway();
|
||||
reminders = MemoryReminderGateway();
|
||||
store = MemoryInstanceStore();
|
||||
gateway = FakeAuthGateway();
|
||||
addresses = MemorySignInAddresses();
|
||||
|
|
@ -133,6 +137,7 @@ void main() {
|
|||
sections: SectionRepository(sectionRows, keys),
|
||||
tasks: TaskRepository(taskRows, keys),
|
||||
labels: LabelRepository(labelRows, keys),
|
||||
reminders: reminders,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -432,6 +437,84 @@ void main() {
|
|||
expect(stored.content?.completed, isFalse);
|
||||
});
|
||||
|
||||
testWidgets("a task's reminder is scheduled on the device before its "
|
||||
'time, and goes once it is done', (tester) async {
|
||||
final tomorrow = Day.of(DateTime.now()).plus(1);
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Dentist',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(tomorrow, time: 9 * 60),
|
||||
reminders: const [30],
|
||||
),
|
||||
);
|
||||
|
||||
expect(reminders.scheduled, hasLength(1));
|
||||
final notice = reminders.scheduled.single;
|
||||
expect(notice.title, 'Dentist');
|
||||
expect(notice.body, 'Due at 09:00');
|
||||
expect(
|
||||
notice.at,
|
||||
DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 8, 30),
|
||||
);
|
||||
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.byTooltip('Mark as done'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(reminders.scheduled, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("a timed task's sheet sets its reminders, asking first to "
|
||||
'notify; refused, it says how to allow it', (tester) async {
|
||||
final tomorrow = Day.of(DateTime.now()).plus(1);
|
||||
reminders.allowed = false;
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(title: 'Undated', projectId: inbox()),
|
||||
);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Call the bank',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(tomorrow, time: 14 * 60),
|
||||
),
|
||||
);
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Undated'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No reminders'), findsNothing, reason: 'no time');
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Call the bank'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('No reminders'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('15 minutes before'));
|
||||
await tester.tap(find.text('At its time'));
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(reminders.asked, 1);
|
||||
expect(
|
||||
find.textContaining('Notifications are off for Halkyon Todo'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('At its time, 15 minutes before'), findsOneWidget);
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
expect(
|
||||
stored!
|
||||
.firstWhere((t) => t.content?.title == 'Call the bank')
|
||||
.content
|
||||
?.reminders,
|
||||
[0, 15],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Today puts what is overdue first; Reschedule moves it to '
|
||||
'the day picked', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
|
|
@ -562,6 +645,148 @@ void main() {
|
|||
expect(find.textContaining('Tomorrow'), findsWidgets);
|
||||
expect(find.text('Dentist'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("search, from any tab's app bar, finds tasks and projects "
|
||||
'as typed; a task found opens, back returns to the tab', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Buy oat milk',
|
||||
projectId: inbox(),
|
||||
description: 'From the Café',
|
||||
),
|
||||
);
|
||||
await seed(tester, TaskContent(title: 'Call Anna', projectId: inbox()));
|
||||
await goTab(tester, 'Upcoming');
|
||||
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Search runs on this device'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'cafe oat');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Buy oat milk'), findsOneWidget);
|
||||
expect(find.text('Call Anna'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'nothing like it');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Nothing found for “nothing like it”.'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'inb');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(ListTile, 'Inbox'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'anna');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Call Anna'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(TextField, 'Call Anna'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(WeekStrip), findsOneWidget, reason: 'Upcoming again');
|
||||
});
|
||||
|
||||
testWidgets('from expanded windows on, a task opens beside the list, '
|
||||
'another in its place, and closes again; below, in a sheet', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(tester, TaskContent(title: 'Milk', projectId: inbox()));
|
||||
await seed(tester, TaskContent(title: 'Bread', projectId: inbox()));
|
||||
await goTab(tester, 'Inbox');
|
||||
tester.view.physicalSize = const Size(1300, 900);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Milk'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
expect(find.byType(VerticalDivider), findsOneWidget);
|
||||
expect(find.widgetWithText(TextField, 'Milk'), findsOneWidget);
|
||||
expect(find.text('Bread'), findsOneWidget, reason: 'the list stays');
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Milk'),
|
||||
'Oat milk',
|
||||
);
|
||||
await tester.tap(find.text('Bread'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(TextField, 'Bread'), findsOneWidget);
|
||||
final stored = await tester.runAsync(() => tasks().all());
|
||||
expect(
|
||||
{for (final task in stored!) task.content?.title},
|
||||
{'Oat milk', 'Bread'},
|
||||
reason: 'what was typed is kept as another opens',
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(VerticalDivider), findsNothing);
|
||||
|
||||
tester.view.physicalSize = const Size(412, 900);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Bread'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(VerticalDivider), findsNothing);
|
||||
expect(find.byType(BottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
"Upcoming's week strip starts on this week, none before; a week "
|
||||
'ahead picked, the agenda scrolls to it',
|
||||
(tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
await start(tester, usedBefore: true);
|
||||
await goTab(tester, 'Upcoming');
|
||||
final firstWeekday = MaterialLocalizations.of(
|
||||
tester.element(find.byType(WeekStrip)),
|
||||
).firstDayOfWeekIndex;
|
||||
final later = WeekStrip.weekOf(today, firstWeekday).plus(21);
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: 'Haircut',
|
||||
projectId: inbox(),
|
||||
date: TaskDate(later),
|
||||
),
|
||||
);
|
||||
double top(Finder finder) => tester.getRect(finder).top;
|
||||
expect(find.byKey(WeekStrip.dayKey(today)), findsOneWidget);
|
||||
expect(find.byKey(WeekStrip.dayKey(later)), findsNothing);
|
||||
expect(
|
||||
top(find.text('Haircut')),
|
||||
greaterThan(900),
|
||||
reason: 'far below',
|
||||
);
|
||||
expect(find.byTooltip('Previous week'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<IconButton>(
|
||||
find.widgetWithIcon(IconButton, LucideIcons.chevronLeft),
|
||||
)
|
||||
.onPressed,
|
||||
isNull,
|
||||
reason: 'no week before this one',
|
||||
);
|
||||
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await tester.tap(find.byTooltip('Following week'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
expect(find.byKey(WeekStrip.dayKey(later)), findsOneWidget);
|
||||
expect(
|
||||
tester.getSemantics(find.byKey(WeekStrip.dayKey(later))),
|
||||
isSemantics(isSelected: true, isButton: true, hasSelectedState: true),
|
||||
);
|
||||
expect(top(find.text('Haircut')), lessThan(600), reason: 'scrolled to');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('signed out, it asks how to start, with an account to create '
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
|
||||
|
||||
/// The system's notifications in memory: what is scheduled, and whether the
|
||||
/// person let the app show them.
|
||||
class MemoryReminderGateway() implements ReminderGateway {
|
||||
/// What is scheduled now.
|
||||
List<ReminderNotice> scheduled = [];
|
||||
|
||||
/// How often the scheduled ones were replaced.
|
||||
int replaced = 0;
|
||||
|
||||
/// What the person answers when asked.
|
||||
bool allowed = true;
|
||||
|
||||
/// How often they were asked.
|
||||
int asked = 0;
|
||||
|
||||
@override
|
||||
Future<void> replace(
|
||||
List<ReminderNotice> notices, {
|
||||
required String channel,
|
||||
}) async {
|
||||
scheduled = [...notices];
|
||||
replaced++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> askPermission() async {
|
||||
asked++;
|
||||
return allowed;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue