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:
Tizian 「ティツィアーン」 2026-11-01 17:28:37 +01:00
commit 36f1e9a25c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -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 {