feat(todo): tasks chosen together, quick add reading what is typed (ADR 0033, D14, D15)

- A section's heading stays atop a project's list while its tasks
  scroll beneath it (slivers, PinnedHeaderSliver).
- Upcoming's month unfolds in place beneath its strip, from its name
  or its handle; a button beside the month goes back to today.
- Tasks are chosen together: a row swiped right or held, or Select
  tasks in Today's, Upcoming's and a project's menu; a bar of date,
  move, labels, priority and more (complete, duplicate, deadline,
  delete) takes the navigation bar's place. A row swiped left asks
  for its date.
- TaskRepository.move moves tasks with their sub-tasks.
- parseQuickAdd reads #project, @label (an unknown one made),
  {deadline}, !!1 and p1 to p4, !30m, dates, times and recurrences
  (tomorrow 9am, Mon 18:00, ev Tue 7pm, every 2 days, every weekday),
  from a table of words per language: English, and German beside it.
- Each token is highlighted and shown on its chip; a tap keeps it as
  text, and a chip picked takes it out of the title.
- Quick add shows its title alone at first, its chips once typing
  starts, and the description and deadline behind its + (the
  description also from the down arrow).

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 16:53:59 +01:00
commit 4960234a63
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
@ -12,6 +13,7 @@ import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -352,6 +354,117 @@ void main() {
expect(taskRows.byId.values.single.keyId, isNotNull);
});
group('quick add reads what is typed (D15)', () {
Future<void> open(WidgetTester tester, String text) async {
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
expect(
find.widgetWithText(ActionChip, 'Priority'),
findsNothing,
reason: 'the title alone at first',
);
await tester.enterText(find.byKey(quickAddTitleKey), text);
await tester.pumpAndSettle();
}
Future<void> add(WidgetTester tester) async {
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
}
testWidgets('typed tokens set the task, shown on its chips, a label '
'unknown made', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Buy milk tomorrow 6pm p1 @Garden');
expect(find.widgetWithText(ActionChip, 'Priority 1'), findsOneWidget);
expect(find.widgetWithText(ActionChip, '1 label'), findsOneWidget);
expect(
find.text('Tap a highlighted word to keep it as text.'),
findsOneWidget,
);
await add(tester);
final stored = await tester.runAsync(() => tasks().all());
final content = stored!.single.content!;
expect(content.title, 'Buy milk');
expect(
content.date,
TaskDate(Day.of(DateTime.now()).plus(1), time: 18 * 60),
);
expect(content.priority, Priority.p1);
final labels = await tester.runAsync(
() => LabelRepository(labelRows, keys).all(),
);
expect(labels!.single.content?.name, 'Garden');
expect(content.labelIds, [labels.single.id]);
});
testWidgets('a token tapped stays text', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
const text = 'Watch Friday';
await open(tester, text);
expect(find.textContaining('Friday,'), findsNothing);
final editable = tester.state<EditableTextState>(
find.descendant(
of: find.byKey(quickAddTitleKey),
matching: find.byType(EditableText),
),
);
final render = editable.renderEditable;
final caret = render.getLocalRectForCaret(
TextPosition(offset: text.indexOf('Friday') + 2),
);
await tester.tapAt(render.localToGlobal(caret.center));
await tester.pumpAndSettle();
expect(
find.text('Tap a highlighted word to keep it as text.'),
findsNothing,
);
await add(tester);
final stored = await tester.runAsync(() => tasks().all());
expect(stored!.single.content?.title, 'Watch Friday');
expect(stored.single.content?.date, isNull);
});
testWidgets('a chip picked takes its token out of the title; the down '
'arrow and the + open the description', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await open(tester, 'Pay tomorrow');
await tester.tap(find.widgetWithText(ActionChip, 'Tomorrow'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Today').last);
await tester.pumpAndSettle();
expect(
tester
.widget<TextField>(find.byKey(quickAddTitleKey))
.controller
?.text,
'Pay',
);
expect(find.widgetWithText(ActionChip, 'Today'), findsOneWidget);
await tester.tap(find.byKey(quickAddTitleKey));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, 'Description'), findsOneWidget);
await tester.tap(find.byTooltip('More').last);
await tester.pumpAndSettle();
expect(
find.text('Description'),
findsOneWidget,
reason: 'the field alone: the menu no longer offers it',
);
});
});
testWidgets('a task added on Today is dated today and shows there with '
'its project', (tester) async {
await start(tester, usedBefore: true);
@ -796,6 +909,220 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets("a section's heading stays atop the list while its tasks "
'scroll beneath it, until the next section pushes it away', (
tester,
) async {
await start(tester, usedBefore: true);
final sections = SectionRepository(sectionRows, keys);
final errands = await tester.runAsync(
() => sections.add(SectionContent(name: 'Errands', projectId: inbox())),
);
await tester.runAsync(
() => sections.add(
SectionContent(name: 'Later', projectId: inbox(), position: 1),
),
);
sectionRows.bringRemoteChanges();
for (var i = 0; i < 20; i++) {
await tester.runAsync(
() => tasks().add(
TaskContent(
title: 'Errand $i',
projectId: inbox(),
sectionId: errands,
position: i.toDouble(),
),
),
);
}
taskRows.bringRemoteChanges();
await tester.pumpAndSettle();
await goTab(tester, 'Inbox');
final heading = find.textContaining('Errands');
final listTop = tester.getBottomLeft(find.byType(AppBar)).dy;
await tester.drag(find.text('Errand 2'), const Offset(0, -400));
await tester.pumpAndSettle();
expect(find.text('Errand 0').hitTestable(), findsNothing);
expect(heading, findsOneWidget);
expect(tester.getTopLeft(heading).dy, greaterThanOrEqualTo(listTop));
expect(tester.getTopLeft(heading).dy, lessThan(listTop + 40));
});
group('choosing tasks (D14)', () {
Future<void> seedToday(WidgetTester tester, List<String> titles) async {
final today = Day.of(DateTime.now());
for (final title in titles) {
await seed(
tester,
TaskContent(
title: title,
projectId: inbox(),
date: TaskDate(today),
),
);
}
}
Finder row(String title) => find.widgetWithText(ListTile, title);
testWidgets('a row swiped right is chosen and a tap chooses another; '
"the bar of what to do with them takes the navigation bar's "
'place, and sets their priority', (tester) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants', 'Post letter', 'Cook']);
await tester.drag(row('Water plants'), const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.text('1 task selected'), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
expect(find.byTooltip('Add task'), findsNothing);
expect(find.text('Water plants'), findsOneWidget, reason: 'kept');
await tester.tap(row('Post letter'));
await tester.pumpAndSettle();
expect(find.text('2 tasks selected'), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing, reason: 'no sheet');
await tester.tap(find.byTooltip('Priority'));
await tester.pumpAndSettle();
await tester.tap(find.text('Priority 1'));
await tester.pumpAndSettle();
expect(find.text('2 tasks selected'), findsNothing, reason: 'ended');
expect(find.byType(NavigationBar), findsOneWidget);
final stored = await tester.runAsync(() => tasks().all());
expect(
{
for (final task in stored!)
task.content!.title: task.content!.priority,
},
{
'Water plants': Priority.p1,
'Post letter': Priority.p1,
'Cook': Priority.none,
},
);
});
testWidgets("Select tasks from Today's menu; a tap chooses, again lets "
'go, and the arrow stops choosing, changing nothing', (
tester,
) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants']);
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Select tasks'));
await tester.pumpAndSettle();
expect(find.text('Select tasks'), findsOneWidget, reason: 'the title');
expect(
tester
.widget<IconButton>(
find.widgetWithIcon(IconButton, LucideIcons.flag),
)
.onPressed,
isNull,
reason: 'none chosen yet',
);
await tester.tap(row('Water plants'));
await tester.pumpAndSettle();
expect(find.text('1 task selected'), findsOneWidget);
await tester.tap(row('Water plants'));
await tester.pumpAndSettle();
expect(find.text('Select tasks'), findsOneWidget);
await tester.tap(find.byTooltip('Stop selecting'));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
await tester.tap(row('Water plants'));
await tester.pumpAndSettle();
expect(
find.byKey(taskPathKey),
findsOneWidget,
reason: 'a tap opens the task again',
);
});
testWidgets('a row swiped left asks for its date', (tester) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants']);
await tester.drag(row('Water plants'), const Offset(-500, 0));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Tomorrow').last);
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsNothing, reason: 'off Today');
final stored = await tester.runAsync(() => tasks().all());
expect(
stored!.single.content?.date?.day,
Day.of(DateTime.now()).plus(1),
);
});
testWidgets('Delete asks first and keeps them when the person changes '
'their mind; Complete can be undone', (tester) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants', 'Post letter']);
await tester.longPress(row('Water plants'));
await tester.pumpAndSettle();
await tester.tap(row('Post letter'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete 2 tasks'));
await tester.pumpAndSettle();
expect(find.text('Delete the tasks?'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('2 tasks selected'), findsOneWidget);
expect(await tester.runAsync(() => tasks().all()), hasLength(2));
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Complete 2 tasks'));
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsNothing);
expect(find.text('2 tasks done'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(find.text('Water plants'), findsOneWidget);
expect(find.text('Post letter'), findsOneWidget);
});
testWidgets("a row held is chosen in the Inbox too, which a project's "
'menu also starts', (tester) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants']);
await tester.longPress(row('Water plants'));
await tester.pumpAndSettle();
expect(find.text('1 task selected'), findsOneWidget);
await tester.tap(find.byTooltip('Stop selecting'));
await tester.pumpAndSettle();
await goTab(tester, 'Inbox');
await tester.longPress(row('Water plants'));
await tester.pumpAndSettle();
expect(find.text('1 task selected'), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
await tester.tap(find.byTooltip('Stop selecting'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.pumpAndSettle();
await tester.tap(find.text('Select tasks'));
await tester.pumpAndSettle();
expect(find.byTooltip('Stop selecting'), findsOneWidget);
});
});
testWidgets('Upcoming lists the days ahead, the empty ones too', (
tester,
) async {
@ -962,6 +1289,77 @@ void main() {
);
});
group("Upcoming's strip (ADR 0033, D8, D14)", () {
testWidgets('once another week shows, a button beside the month goes back '
'to today', (tester) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await goTab(tester, 'Upcoming');
expect(find.byTooltip('Back to today'), findsNothing);
await tester.tap(find.byTooltip('Following week'));
await tester.pumpAndSettle();
expect(find.byKey(WeekStrip.dayKey(today)), findsNothing);
await tester.tap(find.byTooltip('Back to today'));
await tester.pumpAndSettle();
expect(find.byKey(WeekStrip.dayKey(today)), findsOneWidget);
expect(find.byTooltip('Back to today'), findsNothing);
});
testWidgets("the month's name unfolds the month in place, not in a "
'dialog; a day there picks it, and the handle folds it again', (
tester,
) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await goTab(tester, 'Upcoming');
final strip = tester.element(find.byType(WeekStrip));
final firstWeekday = MaterialLocalizations.of(strip).firstDayOfWeekIndex;
final week = WeekStrip.weekOf(today, firstWeekday);
// A day of this month outside this week, where there is one ahead.
final monthEnd = Day(today.year, today.month, today.daysInMonth);
final other = monthEnd > week.plus(6) ? monthEnd : null;
await tester.tap(find.byKey(WeekStrip.monthKey));
await tester.pumpAndSettle();
expect(find.byType(DatePickerDialog), findsNothing);
expect(find.byKey(WeekStrip.dayKey(today)), findsOneWidget);
if (other != null) {
expect(find.byKey(WeekStrip.dayKey(other)), findsOneWidget);
await tester.tap(find.byKey(WeekStrip.dayKey(other)));
await tester.pumpAndSettle();
expect(
tester.getSemantics(find.byKey(WeekStrip.dayKey(other))),
isSemantics(
isSelected: true,
isButton: true,
hasSelectedState: true,
),
);
}
await tester.drag(find.byKey(WeekStrip.handleKey), const Offset(0, -80));
await tester.pumpAndSettle();
expect(
find.byKey(WeekStrip.dayKey(week.plus(-1))),
findsNothing,
reason: 'one week again',
);
await tester.drag(find.byKey(WeekStrip.handleKey), const Offset(0, 120));
await tester.pumpAndSettle();
expect(
find.byKey(WeekStrip.dayKey(Day(today.year, today.month, 1))),
findsOneWidget,
reason:
'the month, dragged open: its first day, not the picked '
"day's week",
);
});
});
testWidgets('signed out, it asks how to start, with an account to create '
'where passwords are offered (ADR 0017), and no GitHub (ADR 0020, '
'D36)', (tester) async {