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
1 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 '
|
||||
|
|
|
|||
Loading…
Reference in a new issue