feat(todo): quick add a small bar docked above the keyboard, as Todoist's

The developer's review of 3 Nov 2026 (ADR 0033, D23): in place of the tall
sheet with its title and Add button, a rounded bar just above the keyboard,
the list still in view behind a lighter barrier: the title, then a row of
boxed buttons (+ for the description and the deadline, the date, the
priority, the labels, the project), each its icon alone until something is
set, and a send button. Its buttons show from the start rather than once
typing begins (D15 had them come with the first letter). Where sheets are
dialogs, the same bar in a dialog.

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-03 21:50:03 +01:00
commit 8438caa489
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 301 additions and 173 deletions

View file

@ -19,23 +19,24 @@ import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// Quick add (ADR 0033, D8): a task's title, and chips for its date, /// Quick add (ADR 0033, D8): a task's title, and buttons for its date,
/// priority, project and labels, and a description where asked for. It /// priority, labels and project, and a description where asked for. It
/// goes into [projectId] (the Inbox where none is given), [sectionId] and /// goes into [projectId] (the Inbox where none is given), [sectionId] and
/// under [parentId] where given, dated [day] where given (at [time], minutes /// under [parentId] where given, dated [day] where given (at [time], minutes
/// after midnight, for [duration] where given: a slot of the 3-day grid, /// after midnight, for [duration] where given: a slot of the 3-day grid,
/// D17), last among its /// D17), last among its siblings, or at [spot] where the add button was
/// siblings, or at [spot] where the add button was dropped (D16), each /// dropped (D16), each next one below the one before. It stays for the next
/// next one below the one before. The sheet stays for the next task; going /// task; going back, or a tap beside it, closes it.
/// back closes it.
/// ///
/// It shows the title alone at first, its chips once typing starts, and its /// On a phone a small bar docked just above the keyboard, as Todoist's,
/// description from its + or the down arrow (the developer's, 1 Nov, D15). /// the list still in view above it ([QuickAddBar]; the developer's review,
/// What the title says is read as it is typed (`parseQuickAdd`): `#project`, /// 3 Nov 2026); where sheets are dialogs (the desktop, expanded windows),
/// `@label`, `{deadline}`, `!!1`, `p1`, `!30m` (as many as typed), `!1pm`, /// the same bar in a dialog. Its description opens from its + or the down
/// dates, times, durations and recurrences in the app's language and /// arrow (D15). What the title says is read as it is typed
/// English, each highlighted and /// (`parseQuickAdd`): `#project`, `@label`, `{deadline}`, `!!1`, `p1`,
/// shown on its chip; a tap on one keeps it as text, and a chip picked /// `!30m` (as many as typed), `!1pm`, dates, times, durations and
/// recurrences in the app's language and English, each highlighted and
/// shown on its button; a tap on one keeps it as text, and a button's pick
/// takes its token out of the title. As `#` or `@` is typed, the projects /// 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 /// or labels it may mean are offered below the title, one tapped
/// completing it. /// completing it.
@ -48,9 +49,8 @@ Future<void> showQuickAdd(
int? time, int? time,
Duration? duration, Duration? duration,
DropSpot? spot, DropSpot? spot,
}) => showAdaptiveSheet<void>( }) {
context, final bar = _QuickAdd(
builder: (context) => _QuickAdd(
projectId: projectId ?? context.read<TodoCubit>().state.inboxId, projectId: projectId ?? context.read<TodoCubit>().state.inboxId,
sectionId: sectionId, sectionId: sectionId,
parentId: parentId, parentId: parentId,
@ -59,8 +59,47 @@ Future<void> showQuickAdd(
null => null, null => null,
}, },
spot: spot, spot: spot,
), );
); if (sheetShowsAsDialog(context)) {
return showDialog<void>(
context: context,
builder: (context) => Dialog(
clipBehavior: Clip.antiAlias,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: bar,
),
),
);
}
return showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
elevation: 0,
// Lighter than a sheet's: the list stays in view behind it.
barrierColor: Colors.black26,
builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(
8,
0,
8,
MediaQuery.viewInsetsOf(context).bottom + 8,
),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
child: bar,
),
),
);
}
/// Quick add's bar, for tests to find.
typedef QuickAddBar = _QuickAdd;
/// The key of quick add's title field. /// The key of quick add's title field.
const quickAddTitleKey = ValueKey('quick-add-title'); const quickAddTitleKey = ValueKey('quick-add-title');
@ -377,97 +416,114 @@ class _QuickAddState() extends State<_QuickAdd> {
}; };
final typing = _title.text.trim().isNotEmpty; final typing = _title.text.trim().isNotEmpty;
final suggestions = _suggestions(l10n, state); final suggestions = _suggestions(l10n, state);
return SheetScaffold( final scheme = theme.colorScheme;
title: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd, const borderless = InputDecoration(
hasSnackBars: true, border: InputBorder.none,
body: Column( enabledBorder: InputBorder.none,
mainAxisSize: MainAxisSize.min, focusedBorder: InputBorder.none,
crossAxisAlignment: CrossAxisAlignment.stretch, filled: false,
children: [ isDense: true,
// The down arrow opens the description, as Todoist's. contentPadding: EdgeInsets.symmetric(vertical: 8),
CallbackShortcuts( );
bindings: { return Semantics(
const SingleActivator(LogicalKeyboardKey.arrowDown): _describe, label: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd,
}, container: true,
child: TextField( child: Padding(
key: quickAddTitleKey, padding: const EdgeInsets.fromLTRB(16, 8, 12, 12),
controller: _title, child: Column(
focusNode: _focus, mainAxisSize: MainAxisSize.min,
autofocus: true, crossAxisAlignment: CrossAxisAlignment.stretch,
textInputAction: TextInputAction.done, children: [
textCapitalization: TextCapitalization.sentences, Center(
decoration: InputDecoration( child: Container(
hintText: l10n.taskTitleHint, width: 32,
helperText: parsed.tokens.isEmpty height: 4,
? null decoration: BoxDecoration(
: l10n.quickAddTokenHint, color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
), borderRadius: BorderRadius.circular(2),
onTap: _tapped, ),
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) // The down arrow opens the description, as Todoist's.
TextField( CallbackShortcuts(
controller: _description, bindings: {
focusNode: _descriptionFocus, const SingleActivator(LogicalKeyboardKey.arrowDown): _describe,
minLines: 1, },
maxLines: 4, child: TextField(
textCapitalization: TextCapitalization.sentences, key: quickAddTitleKey,
decoration: InputDecoration(hintText: l10n.taskDescriptionHint), controller: _title,
focusNode: _focus,
autofocus: true,
textInputAction: TextInputAction.done,
textCapitalization: TextCapitalization.sentences,
style: theme.textTheme.titleLarge,
decoration: borderless.copyWith(
hintText: l10n.taskTitleHint,
helperText: parsed.tokens.isEmpty
? null
: l10n.quickAddTokenHint,
),
onTap: _tapped,
onSubmitted: (_) => _add(),
),
), ),
// The chips come once there is something to add. if (suggestions.isNotEmpty)
AnimatedSize( Material(
duration: const Duration(milliseconds: 150), key: quickAddSuggestionsKey,
alignment: Alignment.topCenter, color: scheme.surfaceContainerHighest,
child: !typing borderRadius: BorderRadius.circular(12),
? const SizedBox(width: double.infinity) clipBehavior: Clip.antiAlias,
: Padding( child: Column(
padding: const EdgeInsets.only(top: 12), mainAxisSize: MainAxisSize.min,
child: Wrap( 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,
focusNode: _descriptionFocus,
minLines: 1,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: borderless.copyWith(
hintText: l10n.taskDescriptionHint,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8, spacing: 8,
runSpacing: 4,
children: [ children: [
ActionChip( _more(context, deadline: deadline),
avatar: Icon( QuickAddButton(
date?.repeat == null icon: date?.repeat == null
? LucideIcons.calendar ? LucideIcons.calendar
: LucideIcons.repeat, : LucideIcons.repeat,
color: switch (date) { tooltip: l10n.dateTitle,
final date? => dateColor( color: switch (date) {
context, final date? => dateColor(context, date.day, today),
date.day, null => null,
today, },
), label: switch (date) {
null => null,
},
),
label: Text(switch (date) {
final date? => dateText(context, date, today), final date? => dateText(context, date, today),
null => l10n.dateTitle, null => null,
}), },
onPressed: () async { onPressed: () async {
if (await pickTaskDate(context, current: date) if (await pickTaskDate(context, current: date)
case (:final date)) { case (:final date)) {
@ -482,16 +538,22 @@ class _QuickAddState() extends State<_QuickAdd> {
} }
}, },
), ),
ActionChip( if (deadline != null)
avatar: Icon( QuickAddButton(
LucideIcons.flag, icon: LucideIcons.target,
color: priorityColor(context, priority), tooltip: l10n.deadlineTitle,
), label: l10n.deadlineOn(
label: Text( dayText(context, deadline, today),
priority == Priority.none ),
? l10n.priorityTitle onPressed: () => _pickDeadline(deadline),
: priorityText(l10n, priority),
), ),
QuickAddButton(
icon: LucideIcons.flag,
tooltip: l10n.priorityTitle,
color: priorityColor(context, priority),
label: priority == Priority.none
? null
: priorityText(l10n, priority),
onPressed: () async { onPressed: () async {
if (await pickPriority(context, priority) if (await pickPriority(context, priority)
case final picked?) { case final picked?) {
@ -500,18 +562,31 @@ class _QuickAddState() extends State<_QuickAdd> {
} }
}, },
), ),
QuickAddButton(
icon: LucideIcons.tag,
tooltip: l10n.labelsTitle,
label: labelCount == 0
? null
: l10n.labelsCount(labelCount),
onPressed: () async {
if (await pickLabels(context, [...labelIds])
case final labels?) {
_dropTokens(const {QuickAddKind.label});
setState(() => _labelIds = labels);
}
},
),
if (widget.parentId == null) if (widget.parentId == null)
ActionChip( QuickAddButton(
avatar: Icon( icon: project?.inbox ?? true
project?.inbox ?? true ? LucideIcons.inbox
? LucideIcons.inbox : LucideIcons.hash,
: LucideIcons.hash, tooltip: l10n.projectPickTitle,
), label: switch (project) {
label: Text(switch (project) {
final project? when !project.inbox => final project? when !project.inbox =>
project.content!.name, project.content!.name,
_ => l10n.shellTabInbox, _ => l10n.shellTabInbox,
}), },
onPressed: () async { onPressed: () async {
if (await pickProject(context, projectId) if (await pickProject(context, projectId)
case final id?) { case final id?) {
@ -525,42 +600,20 @@ class _QuickAddState() extends State<_QuickAdd> {
} }
}, },
), ),
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(
labelCount == 0
? l10n.labelsTitle
: l10n.labelsCount(labelCount),
),
onPressed: () async {
if (await pickLabels(context, [...labelIds])
case final labels?) {
_dropTokens(const {QuickAddKind.label});
setState(() => _labelIds = labels);
}
},
),
if (deadline != null)
ActionChip(
avatar: const Icon(LucideIcons.target),
label: Text(
l10n.deadlineOn(
dayText(context, deadline, today),
),
),
onPressed: () => _pickDeadline(deadline),
),
_more(context, deadline: deadline),
], ],
), ),
), ),
), ),
], const SizedBox(width: 8),
), IconButton.filled(
footer: FilledButton.icon( tooltip: l10n.taskAdd,
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.arrowUp),
label: Text(l10n.taskAdd), onPressed: typing && parsed.title.isNotEmpty ? _add : null,
onPressed: parsed.title.isEmpty ? null : _add, ),
],
),
],
),
), ),
); );
} }
@ -592,6 +645,57 @@ class _QuickAddState() extends State<_QuickAdd> {
), ),
], ],
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
style: IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
}
/// One of quick add's boxed buttons (the developer's review, 3 Nov 2026,
/// as Todoist's): its [icon] alone while nothing is set, named by
/// [tooltip]; with what is set as its [label] beside it.
class const QuickAddButton({
required final IconData icon,
required final String tooltip,
required final VoidCallback onPressed,
final String? label,
final Color? color,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final shown = color ?? scheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Material(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: shown),
if (label case final label?) ...[
const SizedBox(width: 6),
Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: color,
),
),
],
],
),
),
),
),
); );
} }
} }

View file

@ -429,10 +429,10 @@ void main() {
await tester.enterText(find.byType(TextField).first, title); await tester.enterText(find.byType(TextField).first, title);
// Add is on once there is a title. // Add is on once there is a title.
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// The sheet stays for the next one; its close button closes it. // The bar stays for the next one; a tap beside it closes it.
await tester.tap(find.byTooltip('Close').last); await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
@ -707,7 +707,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(quickAddTitleKey), 'Envelopes'); await tester.enterText(find.byKey(quickAddTitleKey), 'Envelopes');
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final all = await tester.runAsync(() => tasks().all()); final all = await tester.runAsync(() => tasks().all());
@ -959,7 +959,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(quickAddTitleKey), 'Call'); await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final stored = await tester.runAsync(() => tasks().all()); final stored = await tester.runAsync(() => tasks().all());
@ -1055,7 +1055,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byKey(quickAddTitleKey), 'Call'); await tester.enterText(find.byKey(quickAddTitleKey), 'Call');
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final stored = await tester.runAsync(() => tasks().all()); final stored = await tester.runAsync(() => tasks().all());
@ -1245,28 +1245,52 @@ void main() {
Future<void> open(WidgetTester tester, String text) async { Future<void> open(WidgetTester tester, String text) async {
await tester.tap(find.byTooltip('Add task')); await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( // Its buttons from the start, as Todoist's bar above the keyboard
find.widgetWithText(ActionChip, 'Priority'), // (the developer's review, 3 Nov 2026).
findsNothing, expect(find.byTooltip('Priority'), findsOneWidget);
reason: 'the title alone at first',
);
await tester.enterText(find.byKey(quickAddTitleKey), text); await tester.enterText(find.byKey(quickAddTitleKey), text);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
Future<void> add(WidgetTester tester) async { Future<void> add(WidgetTester tester) async {
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
testWidgets("a small bar docked just above the keyboard, as Todoist's, "
'the list still in view above it', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await seed(tester, TaskContent(title: 'Call mum', projectId: inbox()));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
addTearDown(tester.view.resetViewInsets);
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
final bar = tester.getRect(find.byType(QuickAddBar));
expect(bar.bottom, lessThanOrEqualTo(900 - 300), reason: 'above it');
expect(bar.bottom, greaterThan(900 - 300 - 24), reason: 'just above');
expect(bar.height, lessThan(200), reason: 'small');
expect(
tester.getRect(find.text('Call mum')).bottom,
lessThan(bar.top),
reason: 'the list in view',
);
expect(find.text('Add task'), findsNothing, reason: 'no title bar');
});
testWidgets('typed tokens set the task, shown on its chips, a label ' testWidgets('typed tokens set the task, shown on its chips, a label '
'unknown made', (tester) async { 'unknown made', (tester) async {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);
await goTab(tester, 'Inbox'); await goTab(tester, 'Inbox');
await open(tester, 'Buy milk tomorrow 6pm p1 @Garden'); await open(tester, 'Buy milk tomorrow 6pm p1 @Garden');
expect(find.widgetWithText(ActionChip, 'Priority 1'), findsOneWidget); expect(
expect(find.widgetWithText(ActionChip, '1 label'), findsOneWidget); find.widgetWithText(QuickAddButton, 'Priority 1'),
findsOneWidget,
);
expect(find.widgetWithText(QuickAddButton, '1 label'), findsOneWidget);
expect( expect(
find.text('Tap a highlighted word to keep it as text.'), find.text('Tap a highlighted word to keep it as text.'),
findsOneWidget, findsOneWidget,
@ -1389,7 +1413,7 @@ void main() {
await goTab(tester, 'Inbox'); await goTab(tester, 'Inbox');
await open(tester, 'Pay tomorrow'); await open(tester, 'Pay tomorrow');
await tester.tap(find.widgetWithText(ActionChip, 'Tomorrow')); await tester.tap(find.widgetWithText(QuickAddButton, 'Tomorrow'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Today').last); await tester.tap(find.widgetWithText(ListTile, 'Today').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -1400,7 +1424,7 @@ void main() {
?.text, ?.text,
'Pay', 'Pay',
); );
expect(find.widgetWithText(ActionChip, 'Today'), findsOneWidget); expect(find.widgetWithText(QuickAddButton, 'Today'), findsOneWidget);
await tester.tap(find.byKey(quickAddTitleKey)); await tester.tap(find.byKey(quickAddTitleKey));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
@ -1874,9 +1898,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Socks'); await tester.enterText(find.byType(TextField).last, 'Socks');
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close').last); await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Sub-tasks 0/1'), findsOneWidget); expect(find.text('Sub-tasks 0/1'), findsOneWidget);
@ -2402,9 +2426,9 @@ void main() {
); );
await tester.enterText(find.byKey(quickAddTitleKey), 'Between'); await tester.enterText(find.byKey(quickAddTitleKey), 'Between');
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close').last); await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(order(tester, ['Alpha', 'Bravo', 'Between']), [ expect(order(tester, ['Alpha', 'Bravo', 'Between']), [
'Alpha', 'Alpha',
@ -2477,7 +2501,7 @@ void main() {
); );
await tester.enterText(find.byKey(quickAddTitleKey), 'Later'); await tester.enterText(find.byKey(quickAddTitleKey), 'Later');
await tester.pump(); await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add')); await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect((await stored(tester))['Later']!.date?.day, tomorrow); expect((await stored(tester))['Later']!.date?.day, tomorrow);