feat(todo): quick add's keys, sub-projects' colours, a task's menu on a right click, and the completed tasks deleted at once

The developer's list of 9 Nov 2026, Todo's part:

- Quick add: the down arrow and Tab move into the description, with its
  cursor (halkyon_editor's fix); Escape closes the projects or labels
  suggested first, then closes quick add, what was typed discarded; the
  project's chip shows the project's colour.
- A project without a colour of its own shows its parent's, the nearest
  ancestor's with one (colorOfProject, TodoCubit.colorOf), wherever a
  project's colour shows; the project editor's "Same colour as its parent"
  keeps it so, and the parent picker shows each project in its colour. A
  project of its own always has one.
- The task pane's x closes it, always; Close left the ⋮ menu.
- A task's row opens its menu on a right click: Mark as done, Date, Move
  to, Labels, Priority, Deadline, Duplicate and Delete, the selection's
  actions for that one task.
- Delete completed tasks, in the ⋮ of the Inbox, Today, Upcoming and a
  project, with Undo: the tasks leave the list at once and are deleted
  once the snack bar goes without its Undo (halkyon_ui's showUndoSnackBar
  gains onKept).

Whats-New: Quick add: the arrow keys and Tab reach the description, and Escape closes it without saving.
Whats-New-De: Schnell hinzufügen: Pfeiltasten und Tab erreichen die Beschreibung, und Escape schließt es, ohne zu speichern.
Whats-New: Sub-projects take their parent's colour unless they have their own.
Whats-New-De: Unterprojekte übernehmen die Farbe ihres übergeordneten Projekts, wenn sie keine eigene haben.
Whats-New: A right click on a task opens its menu, with Move to and more; Delete completed tasks clears a list, with Undo.
Whats-New-De: Ein Rechtsklick auf eine Aufgabe öffnet ihr Menü, mit Verschieben nach und mehr; Erledigte Aufgaben löschen räumt eine Liste auf, mit Rückgängig.
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-09 23:12:43 +01:00
commit 9e1442d89e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 810 additions and 78 deletions

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'
show PointerDeviceKind, kLongPressTimeout;
show PointerDeviceKind, kLongPressTimeout, kSecondaryButton;
import 'package:flutter/rendering.dart' show RenderParagraph;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -47,6 +47,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
@ -3475,6 +3476,113 @@ void main() {
reason: 'the field alone: the menu no longer offers it',
);
});
/// Whether the focus is in the description's editor.
bool describing() =>
FocusManager.instance.primaryFocus?.context
?.findAncestorWidgetOfExactType<NoteComposer>() !=
null;
testWidgets('the down arrow moves the focus into the description '
"(the developer's review, 9 Nov 2026)", (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Pay rent');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(describing(), isTrue);
});
testWidgets('Tab moves from the title into the open description '
"(the developer's review, 9 Nov 2026)", (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Pay rent');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.tap(find.byKey(quickAddTitleKey));
await tester.pumpAndSettle();
expect(describing(), isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(describing(), isTrue);
});
testWidgets('Escape closes quick add and discards what was typed '
"(the developer's review, 9 Nov 2026)", (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Pay rent');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byKey(quickAddTitleKey), findsNothing);
final stored = await tester.runAsync(() => tasks().all());
expect(stored, isEmpty);
});
testWidgets('Escape closes the suggestions first, keeping quick add '
"(the developer's review, 9 Nov 2026)", (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Pay rent #');
expect(find.byKey(quickAddSuggestionsKey), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.byKey(quickAddSuggestionsKey), findsNothing);
expect(find.byKey(quickAddTitleKey), findsOneWidget);
expect(
tester
.widget<TextField>(find.byKey(quickAddTitleKey))
.controller
?.text,
'Pay rent #',
);
});
});
testWidgets("the Inbox's menu deletes its completed tasks once Undo "
"has gone, and Undo keeps them (the developer's review, 9 Nov 2026)", (
tester,
) async {
await start(tester, usedBefore: true);
final done = DateTime(2026, 11, 8);
for (final title in ['Old', 'Older']) {
await seed(
tester,
TaskContent(title: title, projectId: inbox(), completedAt: done),
);
}
await seed(tester, TaskContent(title: 'Open', projectId: inbox()));
await goTab(tester, 'Inbox');
Future<void> deleteCompleted() async {
// Today's menu stays behind the Inbox, out of reach.
await tester.tap(find.byTooltip('More').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Delete completed tasks'));
await tester.pump();
await tester.pump();
}
await deleteCompleted();
expect(find.text('2 completed tasks deleted'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(await tester.runAsync(() => tasks().all()), hasLength(3));
await deleteCompleted();
await tester.pump(const Duration(seconds: 4));
await tester.pumpAndSettle();
final left = await tester.runAsync(() => tasks().all());
expect([for (final task in left!) task.content?.title], ['Open']);
});
testWidgets('a task added on Today is dated today and shows there with '
@ -4278,6 +4386,106 @@ void main() {
expect(find.text('Garden'), findsOneWidget);
});
testWidgets("a new sub-project takes its parent's colour until one is "
"picked, and Browse and quick add show it (the developer's review, "
'9 Nov 2026)', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => projects().save(
const ProjectContent(
name: 'Home',
color: PaletteColor.fern,
position: 1,
),
),
);
projectRows.bringRemoteChanges();
await goTab(tester, 'Browse');
await tester.tap(find.byTooltip('Add project'));
await tester.pumpAndSettle();
expect(
find.text('Same color as its parent'),
findsNothing,
reason: 'a project of its own has a colour of its own',
);
await tester.enterText(find.byType(TextField).first, 'Garden');
await tester.tap(find.text('Parent project'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Home').last);
await tester.pumpAndSettle();
final inherit = find.widgetWithText(
CheckboxListTile,
'Same color as its parent',
);
expect(tester.widget<CheckboxListTile>(inherit).value, isTrue);
expect(find.text('Fern'), findsOneWidget);
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
expect((await stored(tester, 'Garden')).content!.color, isNull);
final fern = paletteText(
tester.element(find.text('Garden')),
PaletteColor.fern,
);
Color? hashOf(String name) => tester
.widget<Icon>(
find.descendant(
of: find.widgetWithText(ListTile, name),
matching: find.byIcon(LucideIcons.hash),
),
)
.color;
expect(hashOf('Garden'), fern);
await tester.tap(find.text('Garden'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Add task').last);
await tester.pumpAndSettle();
final chip = find.widgetWithText(QuickAddButton, 'Garden');
expect(
tester
.widget<Icon>(
find.descendant(of: chip, matching: find.byType(Icon)),
)
.color,
fern,
);
});
testWidgets("unticking the parent's colour keeps the colour shown as "
"the sub-project's own (the developer's review, 9 Nov 2026)", (
tester,
) async {
await start(tester, usedBefore: true);
await tester.runAsync(() async {
final home = await projects().save(
const ProjectContent(name: 'Home', color: PaletteColor.fern),
);
await projects().save(
ProjectContent(name: 'Garden', position: 2, parentId: home),
);
});
projectRows.bringRemoteChanges();
await goTab(tester, 'Browse');
await tester.tap(find.text('Garden'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Edit project'));
await tester.pumpAndSettle();
await tester.tap(find.text('Same color as its parent'));
await tester.pump();
await tester.tap(find.byTooltip('Save'));
await tester.pumpAndSettle();
expect(
(await stored(tester, 'Garden')).content!.color,
PaletteColor.fern,
);
});
testWidgets('a project is never offered as its own parent, nor its '
'sub-projects', (tester) async {
await start(tester, usedBefore: true);
@ -4678,6 +4886,80 @@ void main() {
Finder row(String title) => find.widgetWithText(ListTile, title);
testWidgets('a right click on a task opens its menu, which sets its '
"priority and moves it (the developer's review, 9 Nov 2026)", (
tester,
) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => ProjectRepository(projectRows, keys).save(
const ProjectContent(name: 'Garden', color: PaletteColor.fern),
),
);
projectRows.bringRemoteChanges();
await seedToday(tester, ['Water plants', 'Cook']);
await tester.tap(row('Water plants'), buttons: kSecondaryButton);
await tester.pumpAndSettle();
expect(find.byType(BottomSheet), findsNothing, reason: 'no sheet');
for (final entry in [
'Mark as done',
'Date',
'Move to',
'Labels',
'Priority',
'Deadline',
'Duplicate',
'Delete task',
]) {
expect(find.text(entry), findsOneWidget, reason: entry);
}
await tester.tap(find.text('Priority'));
await tester.pumpAndSettle();
await tester.tap(find.text('Priority 2'));
await tester.pumpAndSettle();
await tester.tap(row('Water plants'), buttons: kSecondaryButton);
await tester.pumpAndSettle();
await tester.tap(find.text('Move to'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Garden'));
await tester.pumpAndSettle();
final stored = await tester.runAsync(() => tasks().all());
final water = stored!.singleWhere(
(task) => task.content!.title == 'Water plants',
);
expect(water.content!.priority, Priority.p2);
expect(water.content!.projectId, isNot(inbox()));
expect(
stored
.singleWhere((task) => task.content!.title == 'Cook')
.content!
.priority,
Priority.none,
reason: 'only the task clicked',
);
expect(find.text('1 task selected'), findsNothing, reason: 'none');
});
testWidgets("a task's menu deletes it after asking (the developer's "
'review, 9 Nov 2026)', (tester) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants']);
await tester.tap(row('Water plants'), buttons: kSecondaryButton);
await tester.pumpAndSettle();
await tester.tap(find.text('Delete task'));
await tester.pumpAndSettle();
expect(find.text('Delete the task?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete task'));
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsNothing);
expect(await tester.runAsync(() => tasks().all()), isEmpty);
});
testWidgets('a row swiped right is chosen and a tap chooses another; '
"the bar of what to do with them takes the navigation bar's "
'place, and sets their priority', (tester) async {
@ -5309,9 +5591,14 @@ void main() {
reason: 'what was typed is kept as another opens',
);
// An x beside the menu closes the pane, the menu no longer (the
// developer's review, 9 Nov 2026).
await tester.tap(find.byTooltip('Task options'));
await tester.pumpAndSettle();
await tester.tap(find.text('Close'));
expect(find.text('Close'), findsNothing);
await tester.tapAt(const Offset(5, 5));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.byType(VerticalDivider), findsNothing);

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// Sub-projects (ADR 0033, D28): Browse lists each under its parent, and a
/// project is never moved under itself or a project below it.
@ -63,4 +64,38 @@ void main() {
['Home', 'Garden', 'Beds', 'Kitchen', 'Work'],
);
});
group('Given sub-projects without a colour of their own (inheriting, as '
"Learn's sub-studysets; the developer's review, 9 Nov 2026),", () {
StoredProject coloured(String id, PaletteColor? color, {String? parent}) =>
StoredProject(
id,
ProjectContent(name: id, color: color, parentId: parent),
);
final family = [
coloured('Home', PaletteColor.fern),
coloured('Garden', null, parent: 'Home'),
coloured('Beds', null, parent: 'Garden'),
coloured('Kitchen', PaletteColor.carrot, parent: 'Home'),
coloured('Plain', null),
coloured('A', null, parent: 'B'),
coloured('B', null, parent: 'A'),
];
test("then each shows its nearest ancestor's colour, at any depth.", () {
expect(colorOfProject(family, 'Garden'), PaletteColor.fern);
expect(colorOfProject(family, 'Beds'), PaletteColor.fern);
});
test("then a colour of its own wins over its parent's.", () {
expect(colorOfProject(family, 'Kitchen'), PaletteColor.carrot);
});
test('then a project of its own without one, a circle of parents and '
"an unknown project show the theme's (none).", () {
expect(colorOfProject(family, 'Plain'), isNull);
expect(colorOfProject(family, 'A'), isNull);
expect(colorOfProject(family, 'missing'), isNull);
});
});
}

View file

@ -174,4 +174,46 @@ void main() {
['other'],
);
});
group('Given done and open tasks (Delete completed tasks; the '
"developer's review, 9 Nov 2026),", () {
final tasks = [
task('open'),
task('done', completed: true),
task('done sub', parent: 'open', completed: true),
task('done elsewhere', project: 'work', completed: true),
task('done today', project: 'work', day: today, completed: true),
task('done yesterday', day: today.plus(-1), completed: true),
task('done next week', day: today.plus(7), completed: true),
task('open today', day: today),
];
List<String> ids(List<OpenTask> found) => [
for (final task in found) task.id,
];
test("then a project's are its done tasks, sub-tasks too.", () {
expect(
ids(completedIn(tasks, projectId: 'inbox')),
unorderedEquals([
'done',
'done sub',
'done yesterday',
'done next week',
]),
);
});
test("then Today's are the done tasks dated today.", () {
expect(ids(completedIn(tasks, from: today, until: today)), [
'done today',
]);
});
test("then Upcoming's are the done tasks dated from today on.", () {
expect(
ids(completedIn(tasks, from: today)),
unorderedEquals(['done today', 'done next week']),
);
});
});
}