feat(todo): tasks dragged into place or under another, the add button dropped where the task goes (ADR 0033, D16)
- A project's row, held, is dragged before or after another, or a little to the right to become its sub-task, or onto a section's heading; its sub-tasks go with it, and Order changed offers Undo (TaskRepository.place). The list scrolls at its edges. - The add button is dragged onto a project's list to add the task between two rows, onto a day in Upcoming for that day, onto Today for today; dropped back on its place, an ×, it gives up. - Holding a row now drags it; a swipe to the right selects it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4960234a63
commit
36f1e9a25c
1 changed files with 903 additions and 92 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart' show kLongPressTimeout;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -14,6 +15,7 @@ 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_drag.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';
|
||||
|
|
@ -1070,7 +1072,7 @@ void main() {
|
|||
'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.drag(row('Water plants'), const Offset(500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(row('Post letter'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -1097,18 +1099,18 @@ void main() {
|
|||
expect(find.text('Post letter'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a row held is chosen in the Inbox too, which a project's "
|
||||
testWidgets("a row swiped 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.drag(row('Water plants'), const Offset(500, 0));
|
||||
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.drag(row('Water plants'), const Offset(500, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('1 task selected'), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
|
|
@ -1123,6 +1125,212 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('dragging (D16)', () {
|
||||
Finder row(String title) => find.widgetWithText(ListTile, title);
|
||||
|
||||
Future<Map<String, TaskContent>> stored(WidgetTester tester) async {
|
||||
final all = await tester.runAsync(() => tasks().all());
|
||||
return {for (final task in all!) task.content!.title: task.content!};
|
||||
}
|
||||
|
||||
/// The Inbox holding [titles] in their order.
|
||||
Future<void> inboxOf(WidgetTester tester, List<String> titles) async {
|
||||
await start(tester, usedBefore: true);
|
||||
for (final (index, title) in titles.indexed) {
|
||||
await seed(
|
||||
tester,
|
||||
TaskContent(
|
||||
title: title,
|
||||
projectId: inbox(),
|
||||
position: index.toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
await goTab(tester, 'Inbox');
|
||||
}
|
||||
|
||||
/// Moves a pressed pointer from [from] to [to] in steps, held first
|
||||
/// where [hold], and lets go.
|
||||
Future<void> dragFrom(
|
||||
WidgetTester tester,
|
||||
Offset from,
|
||||
Offset to, {
|
||||
bool hold = true,
|
||||
}) async {
|
||||
final gesture = await tester.startGesture(from);
|
||||
if (hold) {
|
||||
await tester.pump(
|
||||
kLongPressTimeout + const Duration(milliseconds: 50),
|
||||
);
|
||||
}
|
||||
for (var step = 1; step <= 10; step++) {
|
||||
await gesture.moveTo(Offset.lerp(from, to, step / 10)!);
|
||||
await tester.pump();
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
List<String> order(WidgetTester tester, List<String> titles) =>
|
||||
[...titles]..sort(
|
||||
(a, b) => tester
|
||||
.getCenter(row(a))
|
||||
.dy
|
||||
.compareTo(tester.getCenter(row(b)).dy),
|
||||
);
|
||||
|
||||
testWidgets('a row held and dropped on the upper half of another goes '
|
||||
'before it, which can be undone', (tester) async {
|
||||
await inboxOf(tester, ['Alpha', 'Bravo', 'Charlie']);
|
||||
|
||||
await dragFrom(
|
||||
tester,
|
||||
tester.getCenter(row('Charlie')),
|
||||
tester.getTopLeft(row('Alpha')) + const Offset(100, 6),
|
||||
);
|
||||
|
||||
expect(order(tester, ['Alpha', 'Bravo', 'Charlie']), [
|
||||
'Charlie',
|
||||
'Alpha',
|
||||
'Bravo',
|
||||
]);
|
||||
final after = await stored(tester);
|
||||
expect(after['Charlie']!.position, lessThan(after['Alpha']!.position));
|
||||
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(order(tester, ['Alpha', 'Bravo', 'Charlie']), [
|
||||
'Alpha',
|
||||
'Bravo',
|
||||
'Charlie',
|
||||
]);
|
||||
});
|
||||
|
||||
testWidgets('dropped below another a little to the right, a row becomes '
|
||||
'its sub-task; straight down, it stays a task beside it', (
|
||||
tester,
|
||||
) async {
|
||||
await inboxOf(tester, ['Alpha', 'Bravo', 'Charlie']);
|
||||
|
||||
// Straight: after Bravo, still a task of its own.
|
||||
await dragFrom(
|
||||
tester,
|
||||
tester.getCenter(row('Alpha')),
|
||||
tester.getBottomLeft(row('Bravo')) +
|
||||
Offset(tester.getCenter(row('Alpha')).dx, -6),
|
||||
);
|
||||
expect((await stored(tester))['Alpha']!.parentId, isNull);
|
||||
expect(order(tester, ['Alpha', 'Bravo', 'Charlie']), [
|
||||
'Bravo',
|
||||
'Alpha',
|
||||
'Charlie',
|
||||
]);
|
||||
|
||||
final from = tester.getCenter(row('Charlie'));
|
||||
await dragFrom(
|
||||
tester,
|
||||
from,
|
||||
Offset(from.dx + 80, tester.getBottomLeft(row('Bravo')).dy - 6),
|
||||
);
|
||||
final after = await stored(tester);
|
||||
expect(after['Charlie']!.parentId, isNotNull);
|
||||
final bravo = (await tester.runAsync(() => tasks().all()))!
|
||||
.singleWhere((task) => task.content!.title == 'Bravo');
|
||||
expect(after['Charlie']!.parentId, bravo.id);
|
||||
expect(row('Charlie'), findsNothing, reason: "in Bravo's sheet now");
|
||||
expect(find.text('0/1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("dropped on a section's heading, a row goes first in it", (
|
||||
tester,
|
||||
) async {
|
||||
await inboxOf(tester, ['Alpha']);
|
||||
await tester.runAsync(
|
||||
() => SectionRepository(
|
||||
sectionRows,
|
||||
keys,
|
||||
).add(SectionContent(name: 'Errands', projectId: inbox())),
|
||||
);
|
||||
sectionRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
final heading = find.widgetWithText(ListTile, 'Errands 0');
|
||||
|
||||
await dragFrom(
|
||||
tester,
|
||||
tester.getCenter(row('Alpha')),
|
||||
tester.getBottomLeft(heading) + const Offset(100, -6),
|
||||
);
|
||||
|
||||
final sections = await tester.runAsync(
|
||||
() => SectionRepository(sectionRows, keys).all(),
|
||||
);
|
||||
expect(
|
||||
(await stored(tester))['Alpha']!.sectionId,
|
||||
sections!.single.id,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the add button dropped below a row adds the task there; '
|
||||
'dropped back on its place, an ×, nothing', (tester) async {
|
||||
await inboxOf(tester, ['Alpha', 'Bravo']);
|
||||
final button = tester.getCenter(find.byTooltip('Add task'));
|
||||
|
||||
await dragFrom(
|
||||
tester,
|
||||
button,
|
||||
tester.getBottomLeft(row('Alpha')) + const Offset(100, -6),
|
||||
hold: false,
|
||||
);
|
||||
await tester.enterText(find.byKey(quickAddTitleKey), 'Between');
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(order(tester, ['Alpha', 'Bravo', 'Between']), [
|
||||
'Alpha',
|
||||
'Between',
|
||||
'Bravo',
|
||||
]);
|
||||
|
||||
final gesture = await tester.startGesture(button);
|
||||
final away = button - const Offset(0, 200);
|
||||
for (final (from, to) in [(button, away), (away, button)]) {
|
||||
for (var step = 1; step <= 5; step++) {
|
||||
await gesture.moveTo(Offset.lerp(from, to, step / 5)!);
|
||||
await tester.pump();
|
||||
}
|
||||
expect(find.byKey(AddTaskButton.cancelKey), findsOneWidget);
|
||||
}
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(quickAddTitleKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the add button dropped on a day in Upcoming adds a task '
|
||||
'for that day', (tester) async {
|
||||
final tomorrow = Day.of(DateTime.now()).plus(1);
|
||||
await start(tester, usedBefore: true);
|
||||
await goTab(tester, 'Upcoming');
|
||||
final button = tester.getCenter(find.byTooltip('Add task'));
|
||||
final day = find.byTooltip(RegExp('^Add a task on .*Tomorrow'));
|
||||
expect(day, findsOneWidget);
|
||||
|
||||
await dragFrom(
|
||||
tester,
|
||||
button,
|
||||
tester.getCenter(day) - const Offset(200, 0),
|
||||
hold: false,
|
||||
);
|
||||
await tester.enterText(find.byKey(quickAddTitleKey), 'Later');
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect((await stored(tester))['Later']!.date?.day, tomorrow);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Upcoming lists the days ahead, the empty ones too', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue