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:
parent
b908019969
commit
4960234a63
3 changed files with 3619 additions and 383 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_parser.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.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:test/test.dart';
|
||||
|
||||
/// Typed tokens in quick add (ADR 0033, D15).
|
||||
void main() {
|
||||
// Sunday, 1 Nov 2026, 10:15.
|
||||
final now = DateTime(2026, 11, 1, 10, 15);
|
||||
final today = Day.of(now);
|
||||
const projects = [
|
||||
(id: 'inbox', name: 'Inbox'),
|
||||
(id: 'shop', name: 'Shopping'),
|
||||
(id: 'halkyon', name: 'Halkyon Project'),
|
||||
];
|
||||
const labels = [(id: 'l1', name: 'HalkyonToDo'), (id: 'l2', name: 'errand')];
|
||||
|
||||
ParsedTask parse(
|
||||
String text, {
|
||||
List<QuickAddWords> words = const [QuickAddWords.en],
|
||||
Set<String> ignored = const {},
|
||||
}) => parseQuickAdd(
|
||||
text,
|
||||
now: now,
|
||||
words: words,
|
||||
projects: projects,
|
||||
labels: labels,
|
||||
ignored: ignored,
|
||||
);
|
||||
|
||||
group('symbols, in every language', () {
|
||||
test('#project, the longest name matching, the case aside', () {
|
||||
final added = parse('Clean Gutters #inbox');
|
||||
expect(added.title, 'Clean Gutters');
|
||||
expect(added.projectId, 'inbox');
|
||||
expect(parse('Plan #Halkyon Project now').projectId, 'halkyon');
|
||||
expect(parse('Plan #Halkyon Project now').title, 'Plan now');
|
||||
});
|
||||
|
||||
test('a # naming no project stays text', () {
|
||||
final added = parse('Fix bug #42');
|
||||
expect(added.projectId, isNull);
|
||||
expect(added.title, 'Fix bug #42');
|
||||
expect(added.tokens, isEmpty);
|
||||
});
|
||||
|
||||
test('@label, known ones by ID, unknown ones to be made', () {
|
||||
final added = parse('Change Icon Color @halkyontodo @Garden');
|
||||
expect(added.title, 'Change Icon Color');
|
||||
expect(added.labelIds, ['l1']);
|
||||
expect(added.newLabels, ['Garden']);
|
||||
});
|
||||
|
||||
test('{deadline}', () {
|
||||
final added = parse('Finish HalkyonToDo {Nov 7, 2026}');
|
||||
expect(added.title, 'Finish HalkyonToDo');
|
||||
expect(added.deadline, const Day(2026, 11, 7));
|
||||
expect(added.date, isNull);
|
||||
});
|
||||
|
||||
test('a {} holding no date stays text', () {
|
||||
expect(parse('Write {draft}').deadline, isNull);
|
||||
expect(parse('Write {draft}').title, 'Write {draft}');
|
||||
});
|
||||
|
||||
test('!!1 to !!4 and p1 to p4', () {
|
||||
expect(parse('Clean Cat Litter !!1').priority, Priority.p1);
|
||||
expect(parse('Clean Cat Litter !!1').title, 'Clean Cat Litter');
|
||||
expect(parse('Call p2').priority, Priority.p2);
|
||||
expect(parse('Call P3').priority, Priority.p3);
|
||||
expect(parse('Call p4').priority, Priority.none);
|
||||
expect(parse('Call p4').tokens, hasLength(1));
|
||||
expect(parse('Visit p5').priority, isNull, reason: 'no such one');
|
||||
expect(parse('Read p1x').priority, isNull, reason: 'a word of its own');
|
||||
});
|
||||
|
||||
test('!30m: due that long from now, reminded then', () {
|
||||
final added = parse('Hanging Up Laundry !30m');
|
||||
expect(added.title, 'Hanging Up Laundry');
|
||||
expect(added.date, TaskDate(today, time: 10 * 60 + 45));
|
||||
expect(added.reminders, [0]);
|
||||
expect(parse('Leave !2h').date, TaskDate(today, time: 12 * 60 + 15));
|
||||
});
|
||||
|
||||
test('!30m with a time: reminded that long before it', () {
|
||||
final added = parse('Meeting 3pm !30m');
|
||||
expect(added.date, TaskDate(today, time: 15 * 60));
|
||||
expect(added.reminders, [30]);
|
||||
});
|
||||
});
|
||||
|
||||
group('dates and times, in English', () {
|
||||
test('today, tomorrow and their short forms', () {
|
||||
expect(parse('Pay today').date, TaskDate(today));
|
||||
expect(parse('Pay tod').date, TaskDate(today));
|
||||
expect(parse('Pay tomorrow').date, TaskDate(today.plus(1)));
|
||||
expect(parse('Pay tom').date, TaskDate(today.plus(1)));
|
||||
expect(parse('Pay tomorrow').title, 'Pay');
|
||||
});
|
||||
|
||||
test('a weekday is the coming one, today too; next is after today', () {
|
||||
expect(parse('Gym mon').date?.day, const Day(2026, 11, 2));
|
||||
expect(parse('Gym Friday').date?.day, const Day(2026, 11, 6));
|
||||
expect(parse('Gym sunday').date?.day, today);
|
||||
expect(parse('Gym next sunday').date?.day, const Day(2026, 11, 8));
|
||||
expect(parse('Plan next week').date?.day, const Day(2026, 11, 2));
|
||||
});
|
||||
|
||||
test('in so many days, weeks, months', () {
|
||||
expect(parse('Call in 3 days').date?.day, const Day(2026, 11, 4));
|
||||
expect(parse('Call in 2 weeks').date?.day, const Day(2026, 11, 15));
|
||||
expect(parse('Call in 1 month').date?.day, const Day(2026, 12, 1));
|
||||
});
|
||||
|
||||
test('a day of a month, with its year or the next one to come', () {
|
||||
expect(parse('Trip Nov 7').date?.day, const Day(2026, 11, 7));
|
||||
expect(parse('Trip 7 Nov').date?.day, const Day(2026, 11, 7));
|
||||
expect(parse('Trip 7th November').date?.day, const Day(2026, 11, 7));
|
||||
expect(parse('Trip Jan 3').date?.day, const Day(2027, 1, 3));
|
||||
expect(parse('Trip Mar 3 2028').date?.day, const Day(2028, 3, 3));
|
||||
expect(parse('Trip 2026-12-24').date?.day, const Day(2026, 12, 24));
|
||||
expect(parse('Trip 24.12.').date?.day, const Day(2026, 12, 24));
|
||||
expect(parse('Trip 24.12.2027').date?.day, const Day(2027, 12, 24));
|
||||
expect(parse('Trip Nov 7').title, 'Trip');
|
||||
});
|
||||
|
||||
test('a month alone, or a number alone, is no date', () {
|
||||
expect(parse('May I go').date, isNull);
|
||||
expect(parse('Buy 7 eggs').date, isNull);
|
||||
});
|
||||
|
||||
test('times: 9am, 9:30 pm, 18:00, at 7pm; alone, the next one to come', () {
|
||||
expect(parse('Call 9pm').date, TaskDate(today, time: 21 * 60));
|
||||
expect(parse('Call 9:30 pm').date, TaskDate(today, time: 21 * 60 + 30));
|
||||
expect(parse('Call 18:00').date, TaskDate(today, time: 18 * 60));
|
||||
expect(parse('Call at 7pm').date, TaskDate(today, time: 19 * 60));
|
||||
expect(parse('Call at 7pm').title, 'Call');
|
||||
expect(parse('Call 12am').date?.time, 0);
|
||||
expect(parse('Call 12pm').date?.time, 12 * 60);
|
||||
expect(
|
||||
parse('Call 9am').date,
|
||||
TaskDate(today.plus(1), time: 9 * 60),
|
||||
reason: 'past already today',
|
||||
);
|
||||
});
|
||||
|
||||
test('a day and a time together', () {
|
||||
expect(
|
||||
parse('Dinner Mon 18:00').date,
|
||||
const TaskDate(Day(2026, 11, 2), time: 18 * 60),
|
||||
);
|
||||
expect(
|
||||
parse('Dinner tomorrow at 9am').date,
|
||||
TaskDate(today.plus(1), time: 9 * 60),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('recurrences, in English', () {
|
||||
test(
|
||||
'every day, daily, every 2 days, every second day, every other day',
|
||||
() {
|
||||
expect(
|
||||
parse('Walk every day').date,
|
||||
TaskDate(
|
||||
today,
|
||||
repeat: const Repeat(RepeatUnit.day),
|
||||
),
|
||||
);
|
||||
expect(parse('Walk daily').date?.repeat, const Repeat(RepeatUnit.day));
|
||||
for (final text in [
|
||||
'Walk every 2 days',
|
||||
'Walk every second day',
|
||||
'Walk every other day',
|
||||
]) {
|
||||
expect(
|
||||
parse(text).date?.repeat,
|
||||
const Repeat(RepeatUnit.day, every: 2),
|
||||
reason: text,
|
||||
);
|
||||
expect(parse(text).title, 'Walk', reason: text);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('every week, every 3 months, yearly', () {
|
||||
expect(
|
||||
parse('Clean every week').date?.repeat,
|
||||
const Repeat(RepeatUnit.week),
|
||||
);
|
||||
expect(
|
||||
parse('Bill every 3 months').date?.repeat,
|
||||
const Repeat(RepeatUnit.month, every: 3),
|
||||
);
|
||||
expect(parse('Tax yearly').date?.repeat, const Repeat(RepeatUnit.year));
|
||||
});
|
||||
|
||||
test('ev Tue 7pm: weekly on Tuesdays at 19:00, from the coming one', () {
|
||||
final added = parse('Choir ev Tue 7pm');
|
||||
expect(added.title, 'Choir');
|
||||
expect(
|
||||
added.date,
|
||||
const TaskDate(
|
||||
Day(2026, 11, 3),
|
||||
time: 19 * 60,
|
||||
repeat: Repeat(RepeatUnit.week, weekdays: {DateTime.tuesday}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('every mon, wed and fri; every weekday', () {
|
||||
expect(
|
||||
parse('Run every mon, wed and fri').date?.repeat,
|
||||
const Repeat(
|
||||
RepeatUnit.week,
|
||||
weekdays: {DateTime.monday, DateTime.wednesday, DateTime.friday},
|
||||
),
|
||||
);
|
||||
final weekdays = parse('Stand-up every weekday 9am').date;
|
||||
expect(weekdays?.repeat?.weekdays, {1, 2, 3, 4, 5});
|
||||
expect(weekdays?.day, const Day(2026, 11, 2), reason: 'Monday, the first');
|
||||
});
|
||||
|
||||
test('every! counts from when it was done', () {
|
||||
expect(
|
||||
parse('Water plants every! 3 days').date?.repeat,
|
||||
const Repeat(RepeatUnit.day, every: 3, fromCompletion: true),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('German, with English beside it', () {
|
||||
const de = [QuickAddWords.de, QuickAddWords.en];
|
||||
|
||||
test('heute, morgen, übermorgen, a weekday, in 3 Tagen', () {
|
||||
expect(parse('Zahlen heute', words: de).date?.day, today);
|
||||
expect(parse('Zahlen morgen', words: de).date?.day, today.plus(1));
|
||||
expect(parse('Zahlen übermorgen', words: de).date?.day, today.plus(2));
|
||||
expect(parse('Sport Montag', words: de).date?.day, const Day(2026, 11, 2));
|
||||
expect(parse('Anrufen in 3 Tagen', words: de).date?.day, today.plus(3));
|
||||
expect(parse('Zahlen morgen', words: de).title, 'Zahlen');
|
||||
});
|
||||
|
||||
test('18 Uhr, um 18:30 Uhr, 7. November', () {
|
||||
expect(parse('Essen 18 Uhr', words: de).date?.time, 18 * 60);
|
||||
expect(
|
||||
parse('Essen um 18:30 Uhr', words: de).date?.time,
|
||||
18 * 60 + 30,
|
||||
);
|
||||
expect(parse('Essen um 18:30 Uhr', words: de).title, 'Essen');
|
||||
expect(
|
||||
parse('Reise 7. November', words: de).date?.day,
|
||||
const Day(2026, 11, 7),
|
||||
);
|
||||
});
|
||||
|
||||
test('jeden Tag, alle 2 Tage, jeden zweiten Tag, jeden Dienstag, '
|
||||
'wöchentlich', () {
|
||||
expect(
|
||||
parse('Gehen jeden Tag', words: de).date?.repeat,
|
||||
const Repeat(RepeatUnit.day),
|
||||
);
|
||||
expect(
|
||||
parse('Gehen alle 2 Tage', words: de).date?.repeat,
|
||||
const Repeat(RepeatUnit.day, every: 2),
|
||||
);
|
||||
expect(
|
||||
parse('Gehen jeden zweiten Tag', words: de).date?.repeat,
|
||||
const Repeat(RepeatUnit.day, every: 2),
|
||||
);
|
||||
expect(
|
||||
parse('Chor jeden Dienstag', words: de).date?.repeat,
|
||||
const Repeat(RepeatUnit.week, weekdays: {DateTime.tuesday}),
|
||||
);
|
||||
expect(
|
||||
parse('Putzen wöchentlich', words: de).date?.repeat,
|
||||
const Repeat(RepeatUnit.week),
|
||||
);
|
||||
});
|
||||
|
||||
test('English words still hold beside German', () {
|
||||
expect(parse('Zahlen tomorrow', words: de).date?.day, today.plus(1));
|
||||
});
|
||||
|
||||
test("German's words don't hold where the app speaks English alone", () {
|
||||
expect(parse('Zahlen morgen').date, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('tokens', () {
|
||||
test('each says where it is in the text and what it is', () {
|
||||
const text = 'Call #Shopping tomorrow p1 @errand';
|
||||
final added = parse(text);
|
||||
expect(
|
||||
[
|
||||
for (final token in added.tokens)
|
||||
(token.kind, text.substring(token.start, token.end)),
|
||||
],
|
||||
[
|
||||
(QuickAddKind.project, '#Shopping'),
|
||||
(QuickAddKind.date, 'tomorrow'),
|
||||
(QuickAddKind.priority, 'p1'),
|
||||
(QuickAddKind.label, '@errand'),
|
||||
],
|
||||
reason: 'in the order of the text',
|
||||
);
|
||||
expect(added.title, 'Call');
|
||||
});
|
||||
|
||||
test('a token tapped off stays text, compared by its text', () {
|
||||
final added = parse('Watch Friday the 13th', ignored: {'friday'});
|
||||
expect(added.date, isNull);
|
||||
expect(added.title, 'Watch Friday the 13th');
|
||||
});
|
||||
|
||||
test('only the first of a kind counts', () {
|
||||
final added = parse('Move tomorrow to friday');
|
||||
expect(added.date?.day, today.plus(1));
|
||||
expect(added.title, 'Move to friday');
|
||||
});
|
||||
|
||||
test('a token inside a word is none', () {
|
||||
expect(parse('Tomorrowland tickets').date, isNull);
|
||||
expect(parse('Monday.com login').date, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -177,6 +177,37 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test('moving tasks to a project takes their sub-tasks along, out of any '
|
||||
'section, and a sub-task moved without its parent becomes a task of '
|
||||
'its own', () async {
|
||||
final tasks = await device(alice);
|
||||
final pack = await tasks.add(
|
||||
const TaskContent(title: 'Pack', projectId: 'inbox', sectionId: 's'),
|
||||
);
|
||||
await tasks.add(
|
||||
TaskContent(title: 'Socks', projectId: 'inbox', parentId: pack),
|
||||
);
|
||||
final trip = await tasks.add(
|
||||
const TaskContent(title: 'Trip', projectId: 'inbox'),
|
||||
);
|
||||
final tickets = await tasks.add(
|
||||
TaskContent(title: 'Tickets', projectId: 'inbox', parentId: trip),
|
||||
);
|
||||
await tasks.add(const TaskContent(title: 'Stay', projectId: 'inbox'));
|
||||
|
||||
await tasks.move([pack, tickets], 'travel');
|
||||
|
||||
final all = {for (final t in await tasks.all()) t.content!.title: t};
|
||||
expect(all['Pack']!.content!.projectId, 'travel');
|
||||
expect(all['Pack']!.content!.sectionId, isNull);
|
||||
expect(all['Socks']!.content!.projectId, 'travel');
|
||||
expect(all['Socks']!.content!.parentId, pack, reason: 'kept under it');
|
||||
expect(all['Tickets']!.content!.projectId, 'travel');
|
||||
expect(all['Tickets']!.content!.parentId, isNull, reason: 'left behind');
|
||||
expect(all['Trip']!.content!.projectId, 'inbox');
|
||||
expect(all['Stay']!.content!.projectId, 'inbox');
|
||||
});
|
||||
|
||||
test('duplicating a task copies it with its sub-tasks, theirs too, right '
|
||||
'after it among its siblings', () async {
|
||||
final tasks = await device(alice);
|
||||
|
|
|
|||
Loading…
Reference in a new issue