feat(todo): each project's Display: completed tasks, grouping, sorting and filters, synced (ADR 0033, D17)

- A Display button beside a project's menu (the Inbox's too), a dot
  on it while the project shows otherwise than at first, opens its
  sheet: completed tasks, grouping (date, deadline, added date,
  priority, label), sorting (name, date, deadline, added date,
  priority), filters by date, deadline, priority and label, and
  Reset all.
- The choices live in the project's sealed payload (ProjectView,
  written only once set), so every device shows the project alike.
- Grouped, the groups stand in the sections' place under pinned
  headings; rows are dragged only while sorted by hand by sections.
- Show done tasks moves from the menu into Display.

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 18:41:12 +01:00
commit 7d1de304c8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1878 additions and 81 deletions

View file

@ -11,6 +11,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
@ -358,6 +359,144 @@ void main() {
expect(taskRows.byId.values.single.keyId, isNotNull);
});
group("a project's Display (D17)", () {
Finder row(String title) => find.widgetWithText(ListTile, title);
Future<void> display(WidgetTester tester) async {
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
}
Future<void> pick(
WidgetTester tester,
String setting,
String value,
) async {
await tester.tap(find.widgetWithText(ListTile, setting));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, value).last);
await tester.pumpAndSettle();
}
Future<void> done(WidgetTester tester) async {
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
await tester.pumpAndSettle();
}
Future<void> inboxOf(WidgetTester tester) async {
await start(tester, usedBefore: true);
for (final (index, (title, priority)) in [
('cherry', Priority.none),
('Apple', Priority.p1),
('banana', Priority.p2),
].indexed) {
await seed(
tester,
TaskContent(
title: title,
projectId: inbox(),
position: index.toDouble(),
priority: priority,
),
);
}
await goTab(tester, 'Inbox');
}
List<String> order(WidgetTester tester) => ['cherry', 'Apple', 'banana']
..sort(
(a, b) => tester
.getCenter(row(a))
.dy
.compareTo(tester.getCenter(row(b)).dy),
);
testWidgets('sorting by name orders the list, kept in the project for '
'every device; a dot marks the button; Reset all puts it back', (
tester,
) async {
await inboxOf(tester);
expect(order(tester), ['cherry', 'Apple', 'banana']);
expect(
tester.widget<Badge>(find.byType(Badge).first).isLabelVisible,
isFalse,
);
await display(tester);
await pick(tester, 'Sorting', 'Name');
await done(tester);
expect(order(tester), ['Apple', 'banana', 'cherry']);
final projects = await tester.runAsync(
() => ProjectRepository(projectRows, keys).all(),
);
expect(
projects!
.singleWhere((project) => project.id == inbox())
.content
?.view
.sorting,
TaskSorting.name,
);
expect(
tester.widget<Badge>(find.byType(Badge).first).isLabelVisible,
isTrue,
);
await display(tester);
await tester.tap(find.text('Reset all'));
await tester.pumpAndSettle();
await done(tester);
expect(order(tester), ['cherry', 'Apple', 'banana']);
});
testWidgets('grouped by priority, each priority under its heading', (
tester,
) async {
await inboxOf(tester);
await display(tester);
await pick(tester, 'Grouping', 'Priority');
await done(tester);
expect(find.text('Priority 1 1'), findsOneWidget);
expect(find.text('Priority 2 1'), findsOneWidget);
expect(find.text('No priority 1'), findsOneWidget);
});
testWidgets('a filter by priority leaves out the others; none left, it '
'says so', (tester) async {
await inboxOf(tester);
await display(tester);
await tester.tap(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.widgetWithText(ListTile, 'Priority 1'), findsOneWidget);
await done(tester);
expect(row('Apple'), findsOneWidget);
expect(row('banana'), findsNothing);
expect(row('cherry'), findsNothing);
});
testWidgets('rows are dragged only while sorted by hand', (tester) async {
await inboxOf(tester);
await display(tester);
await pick(tester, 'Sorting', 'Priority');
await done(tester);
expect(find.byType(DraggableTask), findsNothing);
await display(tester);
await pick(tester, 'Sorting', 'Manual');
await done(tester);
expect(find.byType(DraggableTask), findsNWidgets(3));
});
});
group('quick add reads what is typed (D15)', () {
Future<void> open(WidgetTester tester, String text) async {
await tester.tap(find.byTooltip('Add task'));
@ -573,9 +712,11 @@ void main() {
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
await tester.tap(find.text('Show done tasks'));
await tester.tap(find.text('Completed tasks'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
final title = tester.widget<Text>(find.text('Water plants'));
expect(title.style?.decoration, TextDecoration.lineThrough);

View file

@ -4,6 +4,8 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// A project's payload (ADR 0033, D6): UTF-8 JSON with its schema's version,
@ -26,6 +28,45 @@ void main() {
);
});
test("a project's Display reads back as it was set, by names; left "
'as at first, nothing of it is written (D17)', () {
const view = ProjectView(
completed: true,
grouping: TaskGrouping.label,
sorting: TaskSorting.priority,
date: DayRange.next7Days,
deadline: DayRange.none,
priorities: {Priority.p1, Priority.none},
labelIds: {'l1'},
);
const home = ProjectContent(name: 'Home', view: view);
expect(decodeProject(encodeProject(home)), home);
final written = jsonDecode(
utf8.decode(Uint8List.sublistView(encodeProject(home))),
) as Map<String, Object?>;
expect(written['view'], {
'completed': true,
'grouping': 'label',
'sorting': 'priority',
'date': 'next7Days',
'deadline': 'none',
'priorities': ['p1', 'none'],
'labelIds': ['l1'],
});
});
test('a Display value this version does not know reads as at first', () {
final read = decodeProject(
json({
'schemaVersion': 1,
'name': 'Home',
'view': {'layout': 'timeline', 'sorting': 'mood', 'completed': true},
}),
);
expect(read?.view, const ProjectView(completed: true));
});
test('the payload names its schema and the colour by its name', () {
final written = jsonDecode(
utf8.decode(

View file

@ -0,0 +1,232 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// A project's Display (ADR 0033, D17): what it filters, how it sorts and
/// groups.
void main() {
// Sunday, 1 Nov 2026; weeks from Monday.
const today = Day(2026, 11, 1);
const weekStart = Day(2026, 10, 26);
OpenTask task(
String id, {
double position = 0,
Day? day,
int? time,
Day? deadline,
Priority priority = Priority.none,
List<String> labels = const [],
String? section,
}) => (
id: id,
content: TaskContent(
title: id,
projectId: 'p',
sectionId: section,
position: position,
date: day == null ? null : TaskDate(day, time: time),
deadline: deadline,
priority: priority,
labelIds: labels,
),
);
List<String> ids(List<OpenTask> tasks) => [for (final t in tasks) t.id];
List<String> filtered(List<OpenTask> tasks, ProjectView view) => ids([
for (final t in tasks)
if (view.shows(t.content, today: today, weekStart: weekStart)) t,
]);
test('the default view filters nothing, sorts by hand, groups by '
'sections, done tasks hidden', () {
const view = ProjectView();
expect(view.isDefault, isTrue);
expect(view.layout, ProjectLayout.list);
expect(view.completed, isFalse);
expect(view.grouping, TaskGrouping.none);
expect(view.sorting, TaskSorting.manual);
expect(view.canReorder, isTrue);
expect(view.copyWith(sorting: TaskSorting.name).canReorder, isFalse);
expect(view.copyWith(grouping: TaskGrouping.date).canReorder, isFalse);
expect(view.copyWith(completed: true).isDefault, isFalse);
});
group('filters by date', () {
final tasks = [
task('none'),
task('past', day: today.plus(-1)),
task('today', day: today),
task('in 3', day: today.plus(3)),
task('in 10', day: today.plus(10)),
task('in 29', day: today.plus(29)),
task('in 31', day: today.plus(31)),
];
for (final (range, expected) in [
(
DayRange.all,
['none', 'past', 'today', 'in 3', 'in 10', 'in 29', 'in 31'],
),
(DayRange.today, ['today']),
(DayRange.thisWeek, ['past', 'today']),
(DayRange.next7Days, ['today', 'in 3']),
(DayRange.thisMonth, ['today', 'in 3', 'in 10', 'in 29']),
(DayRange.next30Days, ['today', 'in 3', 'in 10', 'in 29']),
(DayRange.none, ['none']),
]) {
test(range.name, () {
expect(filtered(tasks, ProjectView(date: range)), expected);
});
}
});
test('filters by deadline, priority and label, all at once', () {
final tasks = [
task('a', deadline: today.plus(2), priority: Priority.p1, labels: ['x']),
task('b', deadline: today.plus(2), priority: Priority.p2, labels: ['x']),
task('c', priority: Priority.p1, labels: ['x']),
task('d', deadline: today, priority: Priority.p1, labels: ['y']),
];
expect(
filtered(tasks, const ProjectView(deadline: DayRange.next7Days)),
['a', 'b', 'd'],
);
expect(filtered(tasks, const ProjectView(deadline: DayRange.none)), ['c']);
expect(
filtered(tasks, const ProjectView(priorities: {Priority.p1})),
['a', 'c', 'd'],
);
expect(
filtered(tasks, const ProjectView(priorities: {Priority.none})),
isEmpty,
);
expect(filtered(tasks, const ProjectView(labelIds: {'y'})), ['d']);
expect(
filtered(
tasks,
const ProjectView(
deadline: DayRange.next7Days,
priorities: {Priority.p1},
labelIds: {'x'},
),
),
['a'],
);
});
test(
'sorts by name, date, deadline, priority; ties and the rest by hand',
() {
final tasks = [
task('cherry', position: 1, day: today, time: 9 * 60),
task('Apple', position: 2, deadline: today.plus(1)),
task('banana', position: 3, day: today, priority: Priority.p1),
task('date', day: today.plus(1), priority: Priority.p2),
];
List<String> sorted(TaskSorting sorting) =>
ids([...tasks]..sort(ProjectView(sorting: sorting).compare));
expect(sorted(TaskSorting.manual), ['date', 'cherry', 'Apple', 'banana']);
expect(sorted(TaskSorting.name), ['Apple', 'banana', 'cherry', 'date']);
expect(
sorted(TaskSorting.date),
['banana', 'cherry', 'date', 'Apple'],
reason: 'a day without a time first on it, none last',
);
expect(sorted(TaskSorting.deadline), [
'Apple',
'date',
'cherry',
'banana',
]);
expect(sorted(TaskSorting.priority), [
'banana',
'date',
'cherry',
'Apple',
]);
},
);
group('groups', () {
test('by date: overdue, each day, no date last', () {
final groups = groupTasks(
[
task('later', day: today.plus(3)),
task('none'),
task('past', day: today.plus(-2)),
task('now', day: today),
],
TaskGrouping.date,
today: today,
);
expect(
[for (final g in groups) g.key],
[
const OverdueGroup(),
const DayGroup(today),
DayGroup(today.plus(3)),
const DayGroup(null),
],
);
expect(ids(groups.first.tasks), ['past']);
});
test('by priority, P1 first; by label, a task under each of its '
'labels, those without last', () {
final tasks = [
task('low'),
task('high', priority: Priority.p1, labels: ['b', 'a']),
task('mid', priority: Priority.p3, labels: ['a']),
];
expect(
[
for (final g in groupTasks(
tasks,
TaskGrouping.priority,
today: today,
))
(g.key, ids(g.tasks).join(',')),
],
[
(const PriorityGroup(Priority.p1), 'high'),
(const PriorityGroup(Priority.p3), 'mid'),
(const PriorityGroup(Priority.none), 'low'),
],
);
expect(
[
for (final g in groupTasks(
tasks,
TaskGrouping.label,
today: today,
labelOrder: ['a', 'b'],
))
(g.key, ids(g.tasks).join(',')),
],
[
(const LabelGroup('a'), 'high,mid'),
(const LabelGroup('b'), 'high'),
(const LabelGroup(null), 'low'),
],
);
});
test('by deadline, as by date', () {
final groups = groupTasks(
[task('a', deadline: today), task('b')],
TaskGrouping.deadline,
today: today,
);
expect(
[for (final g in groups) g.key],
[
const DayGroup(today),
const DayGroup(null),
],
);
});
});
}