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

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);