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:
parent
36f1e9a25c
commit
7fe74ed0d5
1 changed files with 187 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue