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
3 changed files with 187 additions and 18 deletions

View file

@ -4,6 +4,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`), and its recurrence (`Repeat`, ADR 0033, D7: `TaskRepository.complete` moves a recurring task on, reopens its sub-tasks and returns what it changed for `restore` to undo; picked through `pickRepeat`) and its reminders, minutes before its time (D5: `planReminders` plans them, `ReminderScheduler` under the person's app hands them to `ReminderGateway`, `SystemReminderGateway` on `flutter_local_notifications`, the system scheduling them but on Linux, where the running app shows them; `TodoPerson.dispose` cancels them; Android's manifest declares the boot receiver and exact alarms, the build desugars). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Inbox, Today (where it opens), Upcoming and Browse (D12), (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description, deadline; its chips once typing starts, the rest behind its +; what is typed read by `parseQuickAdd`, `lib/src/feature/task/model/quick_add_parser.dart`, in `QuickAddWords`' tables of the app's language and English, a table per language, D15) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2; a dialog on the desktop, D11), where the task is on top (`_TaskPath`, its steps opening in place) and its ⋮ menu in place of a close button (when it was added, `addedAt` from its UUID v7; Duplicate, `TaskRepository.duplicate`; its done sub-tasks hidden, `hideDoneSubTasks`; Delete; D13), menus' entries with their icons (`MenuEntry`, D14), sections' headings pinned while their tasks scroll (`ProjectTasks`, slivers), dragging (D16, `task_drag.dart`: a project's rows `DraggableTask` in `TaskDropTarget`s giving each half its `DropSpot`, `TaskRepository.place`; `AddTaskButton` dragged onto those, or onto Today's and Upcoming's `NewTaskDropArea`s, opening quick add there), tasks chosen together (`TaskSelection` held by `TodoShell` in `TaskSelectionScope`; a row swiped right, or *Select tasks* in a ⋮; `TabScreen` then counts them and shows `TaskSelectionBar` in the navigation bar's place; a row swiped left asks for its date; `TaskTile(selectable: false)` in a sheet's sub-tasks and search), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it, its month unfolding in place, a way back to today), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Inbox, Today (where it opens), Upcoming and Browse (D12), (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description, deadline; its chips once typing starts, the rest behind its +, projects and labels suggested as `#` and `@` are typed; what is typed read by `parseQuickAdd`, `lib/src/feature/task/model/quick_add_parser.dart`, in `QuickAddWords`' tables of the app's language and English, a table per language, D15) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2; a dialog on the desktop, D11), where the task is on top (`_TaskPath`, its steps opening in place) and its ⋮ menu in place of a close button (when it was added, `addedAt` from its UUID v7; Duplicate, `TaskRepository.duplicate`; its done sub-tasks hidden, `hideDoneSubTasks`; Delete; D13), menus' entries with their icons (`MenuEntry`, D14), sections' headings pinned while their tasks scroll (`ProjectTasks`, slivers), dragging (D16, `task_drag.dart`: a project's rows `DraggableTask` in `TaskDropTarget`s giving each half its `DropSpot`, `TaskRepository.place`; `AddTaskButton` dragged onto those, or onto Today's and Upcoming's `NewTaskDropArea`s, opening quick add there), tasks chosen together (`TaskSelection` held by `TodoShell` in `TaskSelectionScope`; a row swiped right, or *Select tasks* in a ⋮; `TabScreen` then counts them and shows `TaskSelectionBar` in the navigation bar's place; a row swiped left asks for its date; `TaskTile(selectable: false)` in a sheet's sub-tasks and search), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it, its month unfolding in place, a way back to today), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart` on Linux, `device: linux`, as Todo has no web build); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.

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,

View file

@ -8,7 +8,9 @@ import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
@ -403,6 +405,71 @@ void main() {
expect(content.labelIds, [labels.single.id]);
});
testWidgets('# and @ suggest projects and labels as they are typed; '
'one tapped completes the name', (tester) async {
await start(tester, usedBefore: true);
final shopping = await tester.runAsync(
() => ProjectRepository(
projectRows,
keys,
).save(const ProjectContent(name: 'Shopping', position: 1)),
);
projectRows.bringRemoteChanges();
final errand = await tester.runAsync(
() => LabelRepository(
labelRows,
keys,
).add(const LabelContent(name: 'errand')),
);
labelRows.bringRemoteChanges();
await goTab(tester, 'Inbox');
await open(tester, 'Buy milk #sho');
final suggestion = find.widgetWithText(ListTile, 'Shopping');
expect(suggestion, findsOneWidget);
await tester.tap(suggestion);
await tester.pumpAndSettle();
final title = tester
.widget<TextField>(find.byKey(quickAddTitleKey))
.controller!;
expect(title.text, 'Buy milk #Shopping ');
expect(suggestion, findsNothing, reason: 'done with');
await tester.enterText(
find.byKey(quickAddTitleKey),
'${title.text}@er',
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'errand'));
await tester.pumpAndSettle();
await add(tester);
final stored = await tester.runAsync(() => tasks().all());
final content = stored!.single.content!;
expect(content.title, 'Buy milk');
expect(content.projectId, shopping);
expect(content.labelIds, [errand]);
});
testWidgets('nothing is suggested for a # inside a word, nor where no '
'name matches', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => ProjectRepository(
projectRows,
keys,
).save(const ProjectContent(name: 'Shopping', position: 1)),
);
projectRows.bringRemoteChanges();
await goTab(tester, 'Inbox');
await open(tester, 'Fix C#sho');
expect(find.widgetWithText(ListTile, 'Shopping'), findsNothing);
await tester.enterText(find.byKey(quickAddTitleKey), 'Fix #zz');
await tester.pumpAndSettle();
expect(find.byKey(quickAddSuggestionsKey), findsNothing);
});
testWidgets('a token tapped stays text', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');