feat(todo): Today, Upcoming, Inbox, projects, quick add and the task sheet (ADR 0033, D8, D9)

The tabs hold the person's tasks: Today what is overdue (Reschedule
moving it all to a day, times kept) and what is due today; Upcoming the
overdue ones and the next four weeks day by day; Inbox and each
project's screen from Browse the tasks above and in their sections, done
ones on asking; Browse the projects with their open counts and the
labels. A task's row shows its check in its priority's colour, its date
in the colour of how near it is, its deadline, its sub-tasks done and
its labels; quick add takes its title, date, priority, project, labels
and description and stays for the next; its sheet edits all of it, adds
sub-tasks and deletes it with them after asking. Colours are the
palette's, cerise for overdue and P1 against the strawberry wash.

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 10:25:34 +01:00
commit 030c62ddde
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 3647 additions and 121 deletions

View file

@ -8,6 +8,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -120,10 +121,13 @@ void main() {
'https://code.halkyon.app/project-halkyon/halkyon',
openPerson: (person) async {
opened.add(person);
final projects = ProjectRepository(projectRows, keys);
// As the app does when it opens the person's data.
await projects.ensureInbox();
return TodoPerson(
person,
sync: sync,
projects: ProjectRepository(projectRows, keys),
projects: projects,
sections: SectionRepository(sectionRows, keys),
tasks: TaskRepository(taskRows, keys),
labels: LabelRepository(labelRows, keys),
@ -210,19 +214,17 @@ void main() {
expect(find.widgetWithText(AppBar, 'Today'), findsOneWidget);
});
testWidgets('each tab says tasks come with a later version until they '
'do', (tester) async {
testWidgets('each tab says what it holds while it has nothing', (
tester,
) async {
await start(tester, usedBefore: true);
for (final tab in ['Today', 'Upcoming', 'Inbox', 'Browse']) {
await goTab(tester, tab);
expect(find.widgetWithText(AppBar, tab), findsOneWidget, reason: tab);
expect(
find.text('Tasks come with a later version of Halkyon Todo.'),
findsOneWidget,
reason: tab,
);
}
expect(find.text('Nothing due today'), findsOneWidget);
await goTab(tester, 'Inbox');
expect(find.text('Nothing to do here'), findsOneWidget);
await goTab(tester, 'Browse');
expect(find.text('No projects of your own yet.'), findsOneWidget);
expect(find.text('No labels yet.'), findsOneWidget);
});
testWidgets('the tabs wash down from the top in strawberry', (
@ -289,6 +291,229 @@ void main() {
});
});
group('tasks (ADR 0033, D8)', () {
/// The person's tasks, read past the app as another device would.
TaskRepository tasks() => TaskRepository(taskRows, keys);
/// The Inbox's ID, as the app made it.
String inbox() => ProjectRepository(projectRows, keys).inboxId;
/// Adds [title] through quick add, from the current tab's button.
Future<void> quickAdd(WidgetTester tester, String title) async {
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, title);
// Add is on once there is a title.
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
// The sheet stays for the next one; its close button closes it.
await tester.tap(find.byTooltip('Close').last);
await tester.pumpAndSettle();
}
/// Writes [content] as another device of the person's would, the sync
/// bringing it.
Future<void> seed(WidgetTester tester, TaskContent content) async {
await tester.runAsync(() => tasks().add(content));
taskRows.bringRemoteChanges();
await tester.pumpAndSettle();
}
testWidgets('a task added in the Inbox shows there, sealed on its way', (
tester,
) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await quickAdd(tester, 'Buy milk');
expect(find.text('Buy milk'), findsOneWidget);
final stored = await tester.runAsync(() => tasks().all());
expect(stored!.single.content?.title, 'Buy milk');
expect(stored.single.content?.projectId, inbox());
expect(taskRows.byId.values.single.keyId, isNotNull);
});
testWidgets('a task added on Today is dated today and shows there with '
'its project', (tester) async {
await start(tester, usedBefore: true);
await quickAdd(tester, 'Call Anna');
expect(find.text('Call Anna'), findsOneWidget);
expect(
find.descendant(
of: find.widgetWithText(ListTile, 'Call Anna'),
matching: find.text('Inbox'),
),
findsOneWidget,
);
final stored = await tester.runAsync(() => tasks().all());
expect(stored!.single.content?.date?.day, Day.of(DateTime.now()));
});
testWidgets('done, a task leaves its list and can be undone; shown, it '
'is struck through', (tester) async {
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(title: 'Water plants', projectId: inbox()),
);
await goTab(tester, 'Inbox');
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsNothing);
expect(find.text('Task done'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsOneWidget);
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Show done tasks'));
await tester.pumpAndSettle();
final title = tester.widget<Text>(find.text('Water plants'));
expect(title.style?.decoration, TextDecoration.lineThrough);
});
testWidgets('Today puts what is overdue first; Reschedule moves it to '
'the day picked', (tester) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Pay the bill',
projectId: inbox(),
date: TaskDate(today.plus(-3), time: 8 * 60),
),
);
await tester.pumpAndSettle();
expect(find.text('Overdue'), findsOneWidget);
expect(find.text('Pay the bill'), findsOneWidget);
await tester.tap(find.text('Reschedule'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Today').first);
await tester.pumpAndSettle();
expect(find.text('Overdue'), findsNothing);
expect(find.text('Pay the bill'), findsOneWidget);
final stored = await tester.runAsync(() => tasks().all());
expect(
stored!.single.content?.date,
TaskDate(today, time: 8 * 60),
reason: 'its time kept',
);
});
testWidgets('a project made in Browse opens its screen; its task stays '
'out of the Inbox; deleting it asks, and takes its tasks', (
tester,
) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Browse');
await tester.tap(find.byTooltip('New project'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Home');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Home'), findsOneWidget);
await tester.tap(find.text('Home'));
await tester.pumpAndSettle();
expect(find.widgetWithText(AppBar, 'Home'), findsOneWidget);
await quickAdd(tester, 'Fix the tap');
expect(find.text('Fix the tap'), findsOneWidget);
await goTab(tester, 'Inbox');
expect(find.text('Fix the tap'), findsNothing);
await goTab(tester, 'Browse');
// Browse keeps the screen it was left on.
expect(find.widgetWithText(AppBar, 'Home'), findsOneWidget);
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete project'));
await tester.pumpAndSettle();
expect(find.text('Delete the project?'), findsOneWidget);
await tester.tap(find.text('Delete project').last);
await tester.pumpAndSettle();
expect(find.text('No projects of your own yet.'), findsOneWidget);
expect(await tester.runAsync(() => tasks().all()), isEmpty);
});
testWidgets("a task's sheet sets its priority, adds a sub-task the row "
'counts, and deletes it with its sub-tasks after asking', (
tester,
) async {
await start(tester, usedBefore: true);
await seed(tester, TaskContent(title: 'Pack', projectId: inbox()));
await goTab(tester, 'Inbox');
await tester.pumpAndSettle();
await tester.tap(find.text('Pack'));
await tester.pumpAndSettle();
await tester.tap(find.text('No priority'));
await tester.pumpAndSettle();
await tester.tap(find.text('Priority 1'));
await tester.pumpAndSettle();
expect(find.text('Priority 1'), findsOneWidget);
await tester.tap(find.text('Add sub-task'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Socks');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close').last);
await tester.pumpAndSettle();
expect(find.text('Sub-tasks 0/1'), findsOneWidget);
await tester.tap(find.byTooltip('Delete task'));
await tester.pumpAndSettle();
expect(
find.text('"Pack" is deleted with its sub-task, on all your devices.'),
findsOneWidget,
);
await tester.tap(find.text('Delete task').last);
await tester.pumpAndSettle();
expect(find.text('Pack'), findsNothing);
expect(await tester.runAsync(() => tasks().all()), isEmpty);
});
testWidgets('Upcoming lists the days ahead, the empty ones too', (
tester,
) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(today.plus(2)),
),
);
await goTab(tester, 'Upcoming');
await tester.pumpAndSettle();
expect(find.textContaining('Today'), findsWidgets);
expect(find.textContaining('Tomorrow'), findsWidgets);
expect(find.text('Dentist'), findsOneWidget);
});
});
testWidgets('signed out, it asks how to start, with an account to create '
'where passwords are offered (ADR 0017), and no GitHub (ADR 0020, '
'D36)', (tester) async {

View file

@ -0,0 +1,109 @@
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_views.dart';
/// What Todo's lists show of the person's tasks (ADR 0033, D8).
void main() {
const today = Day(2026, 11, 1);
final done = DateTime(2026, 11, 1, 8);
OpenTask task(
String id, {
String project = 'inbox',
String? section,
String? parent,
double position = 0,
Day? day,
int? time,
Priority priority = Priority.none,
bool completed = false,
}) => (
id: id,
content: TaskContent(
title: id,
projectId: project,
sectionId: section,
parentId: parent,
position: position,
date: day == null ? null : TaskDate(day, time: time),
priority: priority,
completedAt: completed ? done : null,
),
);
test("a project's list: its own tasks above its sections or in one, no "
'sub-tasks, done ones only when asked, in their order', () {
final tasks = [
task('b', position: 2),
task('a', position: 1),
task('sub', parent: 'a'),
task('in section', section: 's'),
task('done', completed: true),
task('elsewhere', project: 'home'),
];
expect(tasksIn(tasks, 'inbox').map((t) => t.id), ['a', 'b']);
expect(
tasksIn(tasks, 'inbox', completed: true).map((t) => t.id),
['done', 'a', 'b'],
);
expect(tasksIn(tasks, 'inbox', sectionId: 's').map((t) => t.id), [
'in section',
]);
expect(subTasksOf(tasks, 'a').map((t) => t.id), ['sub']);
expect(openCountIn(tasks, 'inbox'), 4);
});
test("Today: what is overdue, oldest first, never done ones; then today's, "
'timed first by their time, then by priority', () {
final tasks = [
task('late', day: today.plus(-2)),
task('later', day: today.plus(-1)),
task('late but done', day: today.plus(-3), completed: true),
task('any time', day: today),
task('urgent', day: today, priority: Priority.p1),
task('at 9', day: today, time: 9 * 60),
task('at 7', day: today, time: 7 * 60),
task('done today', day: today, completed: true),
task('tomorrow', day: today.plus(1)),
task('no date'),
];
final (:overdue, :due) = todayOf(tasks, today);
expect(overdue.map((t) => t.id), ['late', 'later']);
expect(due.map((t) => t.id), ['at 7', 'at 9', 'urgent', 'any time']);
expect(
todayOf(tasks, today, completed: true).due.map((t) => t.id),
contains('done today'),
);
});
test('Upcoming: the overdue ones, then every day from today on, empty '
'days kept', () {
final tasks = [
task('late', day: today.plus(-1)),
task('today', day: today),
task('in three days', day: today.plus(3)),
task('too far', day: today.plus(30)),
];
final (:overdue, :days) = upcomingOf(tasks, today, days: 5);
expect(overdue.map((t) => t.id), ['late']);
expect(
[for (final (day, _) in days) day],
[
for (var i = 0; i < 5; i++) today.plus(i),
],
);
expect(
[for (final (_, list) in days) list.map((t) => t.id).toList()],
[
['today'],
<String>[],
<String>[],
['in three days'],
<String>[],
],
);
});
}