feat(todo): quick add suggests projects and labels as # and @ are typed (ADR 0033, D15)

Up to five projects or labels whose name, or a word of it, starts with
what follows the symbol show below the title; one tapped completes it.
None for a symbol inside a word (C#), nor once a name is typed whole.

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 17:32:52 +01:00
commit 7fe74ed0d5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -33,7 +33,9 @@ import 'package:material_ui/material_ui.dart';
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m`, dates, times and
/// recurrences in the app's language and English, each highlighted and
/// shown on its chip; a tap on one keeps it as text, and a chip picked
/// takes its token out of the title.
/// takes its token out of the title. As `#` or `@` is typed, the projects
/// or labels it may mean are offered below the title, one tapped
/// completing it.
Future<void> showQuickAdd(
BuildContext context, {
String? projectId,
@ -55,6 +57,9 @@ Future<void> showQuickAdd(
/// The key of quick add's title field.
const quickAddTitleKey = ValueKey('quick-add-title');
/// The key of the projects or labels suggested as `#` or `@` is typed.
const quickAddSuggestionsKey = ValueKey('quick-add-suggestions');
class const _QuickAdd({
required final String? projectId,
required final String? sectionId,
@ -117,23 +122,95 @@ class _QuickAddState() extends State<_QuickAdd> {
words: QuickAddWords.forLanguage(
Localizations.localeOf(context).languageCode,
),
projects: [
for (final project in state.projects)
if (project.inbox) ...[
(id: project.id, name: l10n.shellTabInbox),
if (l10n.shellTabInbox != 'Inbox') (id: project.id, name: 'Inbox'),
] else if (project.content case final content?)
(id: project.id, name: content.name),
],
labels: [
for (final label in state.labels)
if (label.content case final content?)
(id: label.id, name: content.name),
],
projects: _projects(l10n, state),
labels: _labels(state),
ignored: _ignored,
);
}
/// The projects by the names `#` takes, the Inbox by its name in the
/// app's language and in English.
static List<NamedId> _projects(AppLocalizations l10n, TodoState state) => [
for (final project in state.projects)
if (project.inbox) ...[
(id: project.id, name: l10n.shellTabInbox),
if (l10n.shellTabInbox != 'Inbox') (id: project.id, name: 'Inbox'),
] else if (project.content case final content?)
(id: project.id, name: content.name),
];
static List<NamedId> _labels(TodoState state) => [
for (final label in state.labels)
if (label.content case final content?) (id: label.id, name: content.name),
];
/// The `#` or `@` being typed just before the cursor: the symbol, where
/// it is, and what follows it; none where it stands inside a word.
({String symbol, int start, String typed})? _typing() {
final selection = _title.selection;
if (!selection.isValid || !selection.isCollapsed) {
return null;
}
final text = _title.text;
final at = selection.baseOffset;
for (var i = at - 1; i >= 0 && at - i <= 40; i--) {
final char = text[i];
if (char == '#' || char == '@') {
if (i > 0 && text[i - 1].trim().isNotEmpty) {
return null;
}
return (symbol: char, start: i, typed: text.substring(i + 1, at));
}
}
return null;
}
/// The projects or labels whose name, or a word of it, starts with what
/// follows the `#` or `@` being typed, at most five; none once it names
/// one whole.
List<NamedId> _suggestions(AppLocalizations l10n, TodoState state) {
final typing = _typing();
if (typing == null) {
return const [];
}
final typed = typing.typed.toLowerCase();
final named = typing.symbol == '#'
? _projects(l10n, state)
: _labels(state);
if (named.any((item) => item.name.toLowerCase() == typed)) {
return const [];
}
final seen = <String>{};
return [
for (final item in named)
if ((item.name.toLowerCase().startsWith(typed) ||
item.name
.toLowerCase()
.split(' ')
.any((word) => word.startsWith(typed))) &&
seen.add(item.id))
item,
].take(5).toList();
}
/// Completes the `#` or `@` being typed with [item]'s name.
void _complete(NamedId item) {
final typing = _typing();
if (typing == null) {
return;
}
final completed = '${typing.symbol}${item.name} ';
final end = typing.start + 1 + typing.typed.length;
final text = _title.text.replaceRange(typing.start, end, completed);
_title.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(
offset: typing.start + completed.length,
),
);
_focus.requestFocus();
}
/// Takes the tokens of [kinds] out of the title, a chip having been
/// picked for them.
void _dropTokens(Set<QuickAddKind> kinds) {
@ -293,6 +370,7 @@ class _QuickAddState() extends State<_QuickAdd> {
null => null,
};
final typing = _title.text.trim().isNotEmpty;
final suggestions = _suggestions(l10n, state);
return SheetScaffold(
title: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd,
hasSnackBars: true,
@ -322,6 +400,29 @@ class _QuickAddState() extends State<_QuickAdd> {
onSubmitted: (_) => _add(),
),
),
if (suggestions.isNotEmpty)
Material(
key: quickAddSuggestionsKey,
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final item in suggestions)
ListTile(
dense: true,
leading: Icon(switch (_typing()?.symbol) {
'@' => LucideIcons.tag,
_ when item.id == state.inboxId => LucideIcons.inbox,
_ => LucideIcons.hash,
}),
title: Text(item.name),
onTap: () => _complete(item),
),
],
),
),
if (_describing)
TextField(
controller: _description,