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:
parent
4898d0a04a
commit
7d1de304c8
3 changed files with 1878 additions and 81 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue