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
28 changed files with 810 additions and 78 deletions

View file

@ -132,6 +132,7 @@
"projectDelete": "Projekt löschen", "projectDelete": "Projekt löschen",
"projectDeleteTitle": "Projekt löschen?", "projectDeleteTitle": "Projekt löschen?",
"projectDeleteMessage": "„{name}“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.", "projectDeleteMessage": "„{name}“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
"projectInheritColor": "Gleiche Farbe wie das übergeordnete Projekt",
"projectParent": "Übergeordnetes Projekt", "projectParent": "Übergeordnetes Projekt",
"projectNoParent": "Kein übergeordnetes Projekt", "projectNoParent": "Kein übergeordnetes Projekt",
"projectFavorite": "Favorit", "projectFavorite": "Favorit",
@ -231,7 +232,6 @@
"taskDuplicated": "Aufgabe dupliziert", "taskDuplicated": "Aufgabe dupliziert",
"subTasksHideDone": "Erledigte Unteraufgaben ausblenden", "subTasksHideDone": "Erledigte Unteraufgaben ausblenden",
"subTasksShowDone": "Erledigte Unteraufgaben zeigen", "subTasksShowDone": "Erledigte Unteraufgaben zeigen",
"taskClose": "Schließen",
"taskPath": "Wo die Aufgabe liegt", "taskPath": "Wo die Aufgabe liegt",
"weekToday": "Zurück zu heute", "weekToday": "Zurück zu heute",
"monthPrevious": "Voriger Monat", "monthPrevious": "Voriger Monat",
@ -402,5 +402,7 @@
"locationOffTitle": "Standort ist für Erinnerungen aus", "locationOffTitle": "Standort ist für Erinnerungen aus",
"locationOffHint": "Erinnerungen an einem Ort brauchen „Immer zulassen“.", "locationOffHint": "Erinnerungen an einem Ort brauchen „Immer zulassen“.",
"locationArrived": "Du bist bei {name}", "locationArrived": "Du bist bei {name}",
"locationLeft": "Du hast {name} verlassen" "locationLeft": "Du hast {name} verlassen",
"tasksDeleteCompleted": "Erledigte Aufgaben löschen",
"tasksCompletedDeleted": "{count, plural, =1{1 erledigte Aufgabe gelöscht} other{{count} erledigte Aufgaben gelöscht}}"
} }

View file

@ -282,6 +282,10 @@
} }
} }
}, },
"projectInheritColor": "Same colour as its parent",
"@projectInheritColor": {
"description": "In a sub-project's editor: whether it shows in its parent's colour rather than one of its own (the developer's review, 9 Nov 2026)."
},
"projectParent": "Parent project", "projectParent": "Parent project",
"@projectParent": { "@projectParent": {
"description": "A project's editor (ADR 0033, D28), as Todoist's Add project." "description": "A project's editor (ADR 0033, D28), as Todoist's Add project."
@ -598,7 +602,6 @@
"taskDuplicated": "Task duplicated", "taskDuplicated": "Task duplicated",
"subTasksHideDone": "Hide done sub-tasks", "subTasksHideDone": "Hide done sub-tasks",
"subTasksShowDone": "Show done sub-tasks", "subTasksShowDone": "Show done sub-tasks",
"taskClose": "Close",
"taskPath": "Where the task is", "taskPath": "Where the task is",
"weekToday": "Back to today", "weekToday": "Back to today",
"monthPrevious": "Previous month", "monthPrevious": "Previous month",
@ -1246,5 +1249,18 @@
"type": "String" "type": "String"
} }
} }
},
"tasksDeleteCompleted": "Delete completed tasks",
"@tasksDeleteCompleted": {
"description": "A list's menu: deletes its done tasks, with an Undo (the developer's review, 9 Nov 2026)."
},
"tasksCompletedDeleted": "{count, plural, =1{1 completed task deleted} other{{count} completed tasks deleted}}",
"@tasksCompletedDeleted": {
"description": "The snack bar after Delete completed tasks, with its Undo.",
"placeholders": {
"count": {
"type": "int"
}
}
} }
} }

View file

@ -1,5 +1,6 @@
{ {
"@@locale": "en_US", "@@locale": "en_US",
"projectFavorite": "Favorite", "projectFavorite": "Favorite",
"favoritesTitle": "Favorites" "favoritesTitle": "Favorites",
"projectInheritColor": "Same color as its parent"
} }

View file

@ -892,6 +892,12 @@ abstract class AppLocalizations {
/// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'** /// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'**
String projectDeleteMessage(String name); String projectDeleteMessage(String name);
/// In a sub-project's editor: whether it shows in its parent's colour rather than one of its own (the developer's review, 9 Nov 2026).
///
/// In en, this message translates to:
/// **'Same colour as its parent'**
String get projectInheritColor;
/// A project's editor (ADR 0033, D28), as Todoist's Add project. /// A project's editor (ADR 0033, D28), as Todoist's Add project.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -1486,12 +1492,6 @@ abstract class AppLocalizations {
/// **'Show done sub-tasks'** /// **'Show done sub-tasks'**
String get subTasksShowDone; String get subTasksShowDone;
/// No description provided for @taskClose.
///
/// In en, this message translates to:
/// **'Close'**
String get taskClose;
/// No description provided for @taskPath. /// No description provided for @taskPath.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -2517,6 +2517,18 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'You\'ve left {name}'** /// **'You\'ve left {name}'**
String locationLeft(String name); String locationLeft(String name);
/// A list's menu: deletes its done tasks, with an Undo (the developer's review, 9 Nov 2026).
///
/// In en, this message translates to:
/// **'Delete completed tasks'**
String get tasksDeleteCompleted;
/// The snack bar after Delete completed tasks, with its Undo.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 completed task deleted} other{{count} completed tasks deleted}}'**
String tasksCompletedDeleted(int count);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -487,6 +487,10 @@ class AppLocalizationsDe extends AppLocalizations {
return '„$name“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.'; return '„$name“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.';
} }
@override
String get projectInheritColor =>
'Gleiche Farbe wie das übergeordnete Projekt';
@override @override
String get projectParent => 'Übergeordnetes Projekt'; String get projectParent => 'Übergeordnetes Projekt';
@ -921,9 +925,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get subTasksShowDone => 'Erledigte Unteraufgaben zeigen'; String get subTasksShowDone => 'Erledigte Unteraufgaben zeigen';
@override
String get taskClose => 'Schließen';
@override @override
String get taskPath => 'Wo die Aufgabe liegt'; String get taskPath => 'Wo die Aufgabe liegt';
@ -1574,4 +1575,18 @@ class AppLocalizationsDe extends AppLocalizations {
String locationLeft(String name) { String locationLeft(String name) {
return 'Du hast $name verlassen'; return 'Du hast $name verlassen';
} }
@override
String get tasksDeleteCompleted => 'Erledigte Aufgaben löschen';
@override
String tasksCompletedDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count erledigte Aufgaben gelöscht',
one: '1 erledigte Aufgabe gelöscht',
);
return '$_temp0';
}
} }

View file

@ -487,6 +487,9 @@ class AppLocalizationsEn extends AppLocalizations {
return '\"$name\" is deleted with its sections and tasks, on all your devices.'; return '\"$name\" is deleted with its sections and tasks, on all your devices.';
} }
@override
String get projectInheritColor => 'Same colour as its parent';
@override @override
String get projectParent => 'Parent project'; String get projectParent => 'Parent project';
@ -921,9 +924,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get subTasksShowDone => 'Show done sub-tasks'; String get subTasksShowDone => 'Show done sub-tasks';
@override
String get taskClose => 'Close';
@override @override
String get taskPath => 'Where the task is'; String get taskPath => 'Where the task is';
@ -1572,6 +1572,20 @@ class AppLocalizationsEn extends AppLocalizations {
String locationLeft(String name) { String locationLeft(String name) {
return 'You\'ve left $name'; return 'You\'ve left $name';
} }
@override
String get tasksDeleteCompleted => 'Delete completed tasks';
@override
String tasksCompletedDeleted(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count completed tasks deleted',
one: '1 completed task deleted',
);
return '$_temp0';
}
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).
@ -1583,6 +1597,9 @@ class AppLocalizationsEnGb extends AppLocalizationsEn {
class AppLocalizationsEnUs extends AppLocalizationsEn { class AppLocalizationsEnUs extends AppLocalizationsEn {
AppLocalizationsEnUs() : super('en_US'); AppLocalizationsEnUs() : super('en_US');
@override
String get projectInheritColor => 'Same color as its parent';
@override @override
String get projectFavorite => 'Favorite'; String get projectFavorite => 'Favorite';

View file

@ -1,4 +1,5 @@
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// A project as Browse lists it (ADR 0033, D28): how deep it stands under /// A project as Browse lists it (ADR 0033, D28): how deep it stands under
/// the projects it is a sub-project of, and whether it has sub-projects. /// the projects it is a sub-project of, and whether it has sub-projects.
@ -83,3 +84,24 @@ List<StoredProject> parentChoices(List<StoredProject> projects, String? id) {
if (!excluded.contains(node.project.id)) node.project, if (!excluded.contains(node.project.id)) node.project,
]; ];
} }
/// The colour the project [id] shows in: its own, or else its nearest
/// ancestor's (a sub-project inherits it, as Learn's sub-studysets do; the
/// developer's review, 9 Nov 2026); none, the theme's, where no project up
/// to one of its own has one, or the parents run in a circle.
PaletteColor? colorOfProject(List<StoredProject> projects, String id) {
final byId = {for (final project in projects) project.id: project};
final seen = <String>{};
String? at = id;
while (at != null && seen.add(at)) {
final content = byId[at]?.content;
if (content == null) {
return null;
}
if (content.color case final color?) {
return color;
}
at = content.parentId;
}
return null;
}

View file

@ -58,7 +58,11 @@ class const _ProjectEditor({
class _ProjectEditorState() extends State<_ProjectEditor> { class _ProjectEditorState() extends State<_ProjectEditor> {
late final _name = TextEditingController(text: widget.current?.name); late final _name = TextEditingController(text: widget.current?.name);
late var _description = widget.current?.description; late var _description = widget.current?.description;
late var _color = widget.current?.color ?? PaletteColor.cobalt;
/// Its own colour; none: its parent's, while it has one (a new project
/// takes it at first, as Learn's sub-studysets do; the developer's review,
/// 9 Nov 2026).
late PaletteColor? _color = widget.current?.color;
late var _parentId = widget.current?.parentId; late var _parentId = widget.current?.parentId;
late var _favorite = widget.current?.favorite ?? false; late var _favorite = widget.current?.favorite ?? false;
@ -92,7 +96,7 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
// A new project starts with its layout (D28). // A new project starts with its layout (D28).
null => ProjectContent( null => ProjectContent(
name: name, name: name,
color: _color, color: _savedColor,
description: description, description: description,
parentId: _parentId, parentId: _parentId,
favorite: _favorite, favorite: _favorite,
@ -103,7 +107,7 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
// 4 Nov 2026); a layout picked anew shows at once. // 4 Nov 2026); a layout picked anew shows at once.
final current => current.copyWith( final current => current.copyWith(
name: name, name: name,
color: () => _color, color: () => _savedColor,
description: description, description: description,
parentId: () => _parentId, parentId: () => _parentId,
favorite: _favorite, favorite: _favorite,
@ -115,6 +119,21 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
}); });
} }
/// Whether it shows in its parent's colour.
bool get _inheriting => _parentId != null && _color == null;
/// The colour it shows in: its own, else its parent's, else cobalt.
PaletteColor get _shownColor =>
_color ??
switch (_parentId) {
final parent? => colorOfProject(widget.projects, parent),
null => null,
} ??
PaletteColor.cobalt;
/// What it is saved with: a project of its own always has a colour.
PaletteColor? get _savedColor => _parentId == null ? _shownColor : _color;
StoredProject? get _parent => switch (_parentId) { StoredProject? get _parent => switch (_parentId) {
final id? => widget.projects.where((p) => p.id == id).firstOrNull, final id? => widget.projects.where((p) => p.id == id).firstOrNull,
null => null, null => null,
@ -129,7 +148,7 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
content: SizedBox( content: SizedBox(
width: 320, width: 320,
child: PaletteColorPicker( child: PaletteColorPicker(
selected: _color, selected: _shownColor,
onChanged: (color) => Navigator.of(context).pop(color), onChanged: (color) => Navigator.of(context).pop(color),
), ),
), ),
@ -148,7 +167,13 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
current: _parentId, current: _parentId,
); );
if (picked case (:final parentId)) { if (picked case (:final parentId)) {
setState(() => _parentId = parentId); setState(() {
// Out from under a parent, it keeps the colour it showed.
if (parentId == null) {
_color = _shownColor;
}
_parentId = parentId;
});
} }
} }
@ -205,15 +230,26 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
ListTile( ListTile(
leading: Icon( leading: Icon(
LucideIcons.palette, LucideIcons.palette,
color: paletteText(context, _color), color: paletteText(context, _shownColor),
), ),
title: Text(l10n.colorLabel), title: Text(l10n.colorLabel),
trailing: Text( trailing: Text(
ui.paletteColorName(_color.name), ui.paletteColorName(_shownColor.name),
style: TextStyle(color: muted), style: TextStyle(color: muted),
), ),
onTap: _pickColor, onTap: _pickColor,
), ),
if (_parentId != null)
CheckboxListTile(
// Lined up with the rows' icons.
controlAffinity: ListTileControlAffinity.leading,
title: Text(l10n.projectInheritColor),
value: _inheriting,
// Unticked, it keeps the colour it was showing.
onChanged: (inherit) => setState(
() => _color = inherit ?? false ? null : _shownColor,
),
),
ListTile( ListTile(
leading: const Icon(LucideIcons.folderTree), leading: const Icon(LucideIcons.folderTree),
title: Text(l10n.projectParent), title: Text(l10n.projectParent),
@ -278,6 +314,13 @@ Future<({String? parentId})?> pickParentProject(
contentPadding: EdgeInsetsDirectional.only(start: 16 + 24.0 * depth), contentPadding: EdgeInsetsDirectional.only(start: 16 + 24.0 * depth),
leading: Icon( leading: Icon(
parentId == null ? LucideIcons.circleSlash : LucideIcons.hash, parentId == null ? LucideIcons.circleSlash : LucideIcons.hash,
color: switch (parentId) {
final id? => switch (colorOfProject(projects, id)) {
final color? => paletteText(context, color),
null => null,
},
null => null,
},
), ),
title: Text(name), title: Text(name),
trailing: parentId == current ? const Icon(LucideIcons.check) : null, trailing: parentId == current ? const Icon(LucideIcons.check) : null,

View file

@ -9,6 +9,7 @@ import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.da
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_editor.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/project_editor.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart'; import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart'; import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart'; import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
@ -43,6 +44,13 @@ class const ProjectScreenBody({
child: MenuEntry(LucideIcons.listPlus, l10n.sectionAdd), child: MenuEntry(LucideIcons.listPlus, l10n.sectionAdd),
), ),
selectTasksEntry(context), selectTasksEntry(context),
deleteCompletedEntry(
context,
completedIn(
context.read<TodoCubit>().state.tasks,
projectId: projectId,
),
),
...?menu?.call(context), ...?menu?.call(context),
], ],
), ),
@ -160,7 +168,7 @@ class const ProjectScreen({required final String projectId, super.key})
), ),
], ],
); );
return switch (content.color) { return switch (state.colorOf(projectId)) {
final color? => ScreenColor(color: color.background, child: body), final color? => ScreenColor(color: color.background, child: body),
null => body, null => body,
}; };

View file

@ -177,7 +177,7 @@ class _ReportingScreenState() extends State<ReportingScreen> {
Icon( Icon(
project.inbox ? LucideIcons.inbox : LucideIcons.hash, project.inbox ? LucideIcons.inbox : LucideIcons.hash,
size: 18, size: 18,
color: switch (content.color) { color: switch (state.colorOf(project.id)) {
final color? when !project.inbox => paletteText( final color? when !project.inbox => paletteText(
context, context,
color, color,

View file

@ -8,10 +8,12 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart'; import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart'; import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart'; import 'package:halkyon_todo_flutter/src/feature/label/model/label.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_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart'; import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart'; import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart'; import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// Everything of the person's that Todo's screens show, as it stands on /// Everything of the person's that Todo's screens show, as it stands on
/// this device (ADR 0033, D8): their projects (the Inbox first), sections, /// this device (ADR 0033, D8): their projects (the Inbox first), sections,
@ -55,6 +57,10 @@ final class const TodoState({
.where((project) => project.id == id && project.content != null) .where((project) => project.id == id && project.content != null)
.firstOrNull; .firstOrNull;
/// The colour the project [id] shows in, its own or inherited
/// (`colorOfProject`); none: the theme's.
PaletteColor? colorOf(String id) => colorOfProject(projects, id);
/// The sections of the project [projectId] that open, in their order. /// The sections of the project [projectId] that open, in their order.
List<StoredSection> sectionsOf(String projectId) => [ List<StoredSection> sectionsOf(String projectId) => [
for (final section in sections) for (final section in sections)

View file

@ -283,3 +283,25 @@ TaskContent completedContent(
completions: [...content.completions, now], completions: [...content.completions, now],
); );
} }
/// The done tasks a list's Delete completed tasks deletes (the developer's
/// review, 9 Nov 2026): those of the project [projectId], sub-tasks too,
/// and those dated from [from] to [until], each where given.
List<OpenTask> completedIn(
List<OpenTask> tasks, {
String? projectId,
Day? from,
Day? until,
}) => [
for (final task in tasks)
if (task.content.completed &&
(projectId == null || task.content.projectId == projectId) &&
(from == null && until == null ||
switch (task.content.date?.day) {
final day? =>
(from == null || day.compareTo(from) >= 0) &&
(until == null || day.compareTo(until) <= 0),
null => false,
}))
task,
];

View file

@ -141,6 +141,10 @@ class _QuickAddState() extends State<_QuickAdd> {
Day? _deadline; Day? _deadline;
var _describing = false; var _describing = false;
/// Where the `#`, `@` or `+` stands whose suggestions Escape closed: they
/// stay closed until another one is typed.
int? _dismissedAt;
/// The title said aloud (D29), from the last button while the title is /// The title said aloud (D29), from the last button while the title is
/// empty. /// empty.
late final _dictation = Dictation( late final _dictation = Dictation(
@ -517,11 +521,17 @@ class _QuickAddState() extends State<_QuickAdd> {
null => null, null => null,
}; };
final typing = _title.text.trim().isNotEmpty; final typing = _title.text.trim().isNotEmpty;
final suggestions = _suggestions(l10n, state); final symbol = _typing();
final placing = switch (_typing()) { final dismissed = symbol != null && symbol.start == _dismissedAt;
final suggestions = dismissed
? const <NamedId>[]
: _suggestions(l10n, state);
final placing = switch (symbol) {
_ when dismissed => null,
(symbol: '+', :final typed, start: _) => typed.trim(), (symbol: '+', :final typed, start: _) => typed.trim(),
_ => null, _ => null,
}; };
final suggesting = suggestions.isNotEmpty || placing != null;
final scheme = theme.colorScheme; final scheme = theme.colorScheme;
const borderless = InputDecoration( const borderless = InputDecoration(
border: InputBorder.none, border: InputBorder.none,
@ -550,10 +560,14 @@ class _QuickAddState() extends State<_QuickAdd> {
), ),
), ),
), ),
// The down arrow opens the description, as Todoist's. // The down arrow opens the description, as Todoist's; Escape
// closes the suggestions first, then quick add (the route's).
CallbackShortcuts( CallbackShortcuts(
bindings: { bindings: {
const SingleActivator(LogicalKeyboardKey.arrowDown): _describe, const SingleActivator(LogicalKeyboardKey.arrowDown): _describe,
if (suggesting)
const SingleActivator(LogicalKeyboardKey.escape): () =>
setState(() => _dismissedAt = symbol!.start),
}, },
child: TextField( child: TextField(
key: quickAddTitleKey, key: quickAddTitleKey,
@ -574,7 +588,7 @@ class _QuickAddState() extends State<_QuickAdd> {
onSubmitted: (_) => _add(), onSubmitted: (_) => _add(),
), ),
), ),
if (suggestions.isNotEmpty || placing != null) if (suggesting)
Material( Material(
key: quickAddSuggestionsKey, key: quickAddSuggestionsKey,
color: scheme.surfaceContainerHighest, color: scheme.surfaceContainerHighest,
@ -706,6 +720,15 @@ class _QuickAddState() extends State<_QuickAdd> {
? LucideIcons.inbox ? LucideIcons.inbox
: LucideIcons.hash, : LucideIcons.hash,
tooltip: l10n.projectPickTitle, tooltip: l10n.projectPickTitle,
// In the project's colour, as Browse lists it.
color: switch (project) {
final project? when !project.inbox =>
switch (state.colorOf(project.id)) {
final color? => paletteText(context, color),
null => null,
},
_ => null,
},
label: switch (project) { label: switch (project) {
final project? when !project.inbox => final project? when !project.inbox =>
project.content!.name, project.content!.name,

View file

@ -47,7 +47,7 @@ Future<String?> pickProject(BuildContext context, String? current) {
ListTile( ListTile(
leading: Icon( leading: Icon(
project.inbox ? LucideIcons.inbox : LucideIcons.hash, project.inbox ? LucideIcons.inbox : LucideIcons.hash,
color: switch (content.color) { color: switch (state.colorOf(project.id)) {
final color? => paletteText(context, color), final color? => paletteText(context, color),
null => null, null => null,
}, },

View file

@ -151,13 +151,82 @@ class const TaskSelectionBar({
} }
} }
/// The menu of the task [task], opened by a right click at [position]
/// (the developer's review, 9 Nov 2026): what the selection bar does, for
/// it alone, without choosing it.
Future<void> showTaskMenu(
BuildContext context,
OpenTask task,
Offset position,
) async {
final l10n = AppLocalizations.of(context);
final actions = _SelectionActions(
context,
TaskSelection()..toggle(task.id),
one: true,
);
final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox;
final action = await showMenu<Future<void> Function()>(
context: context,
position: RelativeRect.fromRect(
position & Size.zero,
Offset.zero & overlay.size,
),
items: [
if (!task.content.completed)
PopupMenuItem(
value: actions.complete,
child: MenuEntry(LucideIcons.circleCheck, l10n.taskComplete),
),
PopupMenuItem(
value: actions.date,
child: MenuEntry(LucideIcons.calendar, l10n.selectionDate),
),
PopupMenuItem(
value: actions.move,
child: MenuEntry(LucideIcons.folderInput, l10n.selectionMove),
),
PopupMenuItem(
value: actions.labels,
child: MenuEntry(LucideIcons.tag, l10n.selectionLabels),
),
PopupMenuItem(
value: actions.priority,
child: MenuEntry(LucideIcons.flag, l10n.selectionPriority),
),
PopupMenuItem(
value: actions.deadline,
child: MenuEntry(LucideIcons.target, l10n.selectionDeadline),
),
PopupMenuItem(
value: actions.duplicate,
child: MenuEntry(LucideIcons.copyPlus, l10n.taskDuplicate),
),
const PopupMenuDivider(),
PopupMenuItem(
value: actions.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.taskDelete,
destructive: true,
),
),
],
);
if (action != null && context.mounted) {
await action();
}
}
/// What the bar does to the chosen tasks, written through the person's /// What the bar does to the chosen tasks, written through the person's
/// [TaskRepository] together: shown at once, as choosing ends, and shown /// [TaskRepository] together: shown at once, as choosing ends, and shown
/// as they were again, with a snack bar, where writing fails. /// as they were again, with a snack bar, where writing fails; for a task's
/// own menu ([one]), a selection of it alone that nobody sees.
class _SelectionActions( class _SelectionActions(
final BuildContext _context, final BuildContext _context,
final TaskSelection _selection, final TaskSelection _selection, {
) { final bool one = false,
}) {
AppLocalizations get _l10n => AppLocalizations.of(_context); AppLocalizations get _l10n => AppLocalizations.of(_context);
TaskRepository get _tasks => PersonScope.of<TodoPerson>(_context).tasks; TaskRepository get _tasks => PersonScope.of<TodoPerson>(_context).tasks;
@ -309,11 +378,20 @@ class _SelectionActions(
Future<void> delete() async { Future<void> delete() async {
final l10n = _l10n; final l10n = _l10n;
final count = _selection.ids.length; final count = _selection.ids.length;
final single = switch (_chosen) {
[final task] when one => task,
_ => null,
};
if (await confirmDestructiveAction( if (await confirmDestructiveAction(
_context, _context,
title: l10n.selectionDeleteTitle, title: single == null ? l10n.selectionDeleteTitle : l10n.taskDeleteTitle,
message: l10n.selectionDeleteMessage(count), message: switch (single) {
confirmLabel: l10n.selectionDelete(count), final task? => l10n.taskDeleteMessage(task.content.title),
null => l10n.selectionDeleteMessage(count),
},
confirmLabel: single == null
? l10n.selectionDelete(count)
: l10n.taskDelete,
)) { )) {
final ids = _selection.ids.toList(); final ids = _selection.ids.toList();
final tasks = _tasks; final tasks = _tasks;

View file

@ -545,12 +545,13 @@ class _TaskSheetState() extends State<_TaskSheet> {
), ),
), ),
?action, ?action,
if (action == null) // Beside the menu in the pane, so closing it is a click (the
IconButton( // developer's review, 9 Nov 2026).
tooltip: MaterialLocalizations.of(context).closeButtonTooltip, IconButton(
icon: const Icon(LucideIcons.x), tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
onPressed: _closer(context), icon: const Icon(LucideIcons.x),
), onPressed: _closer(context),
),
], ],
), ),
), ),
@ -566,8 +567,9 @@ class _TaskSheetState() extends State<_TaskSheet> {
} }
/// The task's menu (the developer's, 1 Nov, in place of the close /// The task's menu (the developer's, 1 Nov, in place of the close
/// button): when it was added, Duplicate, hiding or showing its done /// button, which the pane shows beside it since 9 Nov): when it was
/// sub-tasks, Close in the pane, and deleting it, which asks first. /// added, Duplicate, hiding or showing its done sub-tasks, and deleting
/// it, which asks first.
Widget _menu(BuildContext context, OpenTask task, List<OpenTask> subTasks) { Widget _menu(BuildContext context, OpenTask task, List<OpenTask> subTasks) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final content = task.content; final content = task.content;
@ -624,11 +626,6 @@ class _TaskSheetState() extends State<_TaskSheet> {
: l10n.subTasksHideDone, : l10n.subTasksHideDone,
), ),
), ),
if (widget.inPane)
PopupMenuItem(
value: () async => _closer(context)(),
child: MenuEntry(LucideIcons.x, l10n.taskClose),
),
const PopupMenuDivider(), const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
value: () async { value: () async {
@ -1025,7 +1022,7 @@ class const _TaskPath({
Icon( Icon(
project.inbox ? LucideIcons.inbox : LucideIcons.hash, project.inbox ? LucideIcons.inbox : LucideIcons.hash,
size: 18, size: 18,
color: switch (project.content?.color) { color: switch (state.colorOf(project.id)) {
final color? => paletteText(context, color), final color? => paletteText(context, color),
null => muted, null => muted,
}, },

View file

@ -212,7 +212,7 @@ class const TaskTile({
), ),
) )
: TaskCheck(task: task); : TaskCheck(task: task);
final tile = ListTile( Widget tile = ListTile(
selected: chosen, selected: chosen,
selectedTileColor: theme.colorScheme.secondaryContainer, selectedTileColor: theme.colorScheme.secondaryContainer,
selectedColor: theme.colorScheme.onSecondaryContainer, selectedColor: theme.colorScheme.onSecondaryContainer,
@ -268,6 +268,15 @@ class const TaskTile({
null => () => openTaskSheet(context, task.id), null => () => openTaskSheet(context, task.id),
}, },
); );
// A right click opens the task's menu, unless tasks are being chosen
// (the developer's review, 9 Nov 2026).
if (selection == null || !selection.active) {
tile = GestureDetector(
onSecondaryTapUp: (details) =>
unawaited(showTaskMenu(context, task, details.globalPosition)),
child: tile,
);
}
if (selection == null) { if (selection == null) {
return _boxed(context, tile); return _boxed(context, tile);
} }

View file

@ -93,7 +93,7 @@ class _BrowseTabState() extends State<BrowseTab> {
), ),
leading: Icon( leading: Icon(
LucideIcons.hash, LucideIcons.hash,
color: switch (project.content!.color) { color: switch (state.colorOf(project.id)) {
final color? => paletteText(context, color), final color? => paletteText(context, color),
null => null, null => null,
}, },

View file

@ -745,8 +745,7 @@ class const _TaskBlock({
for (final label in state.labels) for (final label in state.labels)
if (label.id == id) ?label.content?.color, if (label.id == id) ?label.content?.color,
].firstOrNull, ].firstOrNull,
TaskColoring.project => TaskColoring.project => state.colorOf(content.projectId),
state.projectOf(content.projectId)?.content?.color,
}; };
final color = final color =
switch (palette) { switch (palette) {

View file

@ -387,7 +387,7 @@ Future<void> _pickProjects(
project.inbox project.inbox
? LucideIcons.inbox ? LucideIcons.inbox
: LucideIcons.hash, : LucideIcons.hash,
color: switch (project.content?.color) { color: switch (state.colorOf(project.id)) {
final color? => paletteText(context, color), final color? => paletteText(context, color),
null => null, null => null,
}, },

Some files were not shown because too many files have changed in this diff Show more