feat(todo): 3-day grid's blocks dragged to another day and time, or onto All day (ADR 0033, D17)

A block held and dragged moves its task to another of the three days
shown and another time, its top snapping to a quarter hour, the time it
would take shown under the pointer, its duration, recurrence and
reminders kept; dropped on a day's All day it loses its time and
duration. Either can be undone for a moment. A later time of a repeating
task doesn't move. DayDropArea's move is now moveTask, shared by both.

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 20:58:12 +01:00
commit 0373f723e7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 399 additions and 49 deletions

File diff suppressed because one or more lines are too long

View file

@ -23,6 +23,69 @@ import 'package:material_ui/material_ui.dart';
final class TaskDrag(final String id) {
/// Where the pointer went down, across the screen.
double startX = 0;
/// How far below a 3-day grid's block's top the pointer went down (D17),
/// so that the block keeps its place under it as it is dropped.
double grabY = 0;
}
/// Moves the task [id] to [day], which can be undone for a moment: its
/// time, duration and recurrence kept, but where [time] is given, at
/// [time] (minutes after midnight), and where [allDay], without a time or
/// duration (D16, D17). Nothing where it is there already.
Future<void> moveTask(
BuildContext context,
String id,
Day day, {
int? time,
bool allDay = false,
}) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final tasks = PersonScope.of<TodoPerson>(context).tasks;
final task = context
.read<TodoCubit>()
.state
.tasks
.where((task) => task.id == id)
.firstOrNull;
if (task == null) {
return;
}
final content = task.content;
final date = content.date;
final to = switch ((time, allDay)) {
(final time?, _) => TaskDate(
day,
time: time,
duration: date?.duration,
repeat: date?.repeat,
),
(null, true) => TaskDate(day, repeat: date?.repeat),
(null, false) => date?.on(day) ?? TaskDate(day),
};
if (to == date) {
return;
}
try {
await tasks.update(id, content.copyWith(date: () => to));
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
return;
}
if (context.mounted) {
final dayName = dayText(context, day, Day.of(clock.now()));
showUndoSnackBar(
context,
message: l10n.taskMovedTo(
switch (to.time) {
final time? => '$dayName, ${timeText(context, time)}',
null => dayName,
},
),
onUndo: () => unawaited(tasks.update(id, content)),
);
}
}
/// The add button being dragged onto a list (D16).
@ -283,7 +346,7 @@ class _DraggableTaskState() extends State<DraggableTask> {
/// A day of Upcoming or a project's Calendar (D16, D17): the add button
/// dropped on it adds a task for [day], in [projectId] where given; a
/// task's row moves there, its time, duration and recurrence
/// task's row moves there ([moveTask]), its time, duration and recurrence
/// kept, which can be undone for a moment. Tinted while either is over it.
class const DayDropArea({
required final Day day,
@ -291,46 +354,12 @@ class const DayDropArea({
final String? projectId,
super.key,
}) extends StatelessWidget {
Future<void> _move(BuildContext context, String id) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final tasks = PersonScope.of<TodoPerson>(context).tasks;
final task = context
.read<TodoCubit>()
.state
.tasks
.where((task) => task.id == id)
.firstOrNull;
if (task == null || task.content.date?.day == day) {
return;
}
final content = task.content;
final moved = content.copyWith(
date: () => content.date?.on(day) ?? TaskDate(day),
);
try {
await tasks.update(id, moved);
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
return;
}
if (context.mounted) {
showUndoSnackBar(
context,
message: l10n.taskMovedTo(
dayText(context, day, Day.of(clock.now())),
),
onUndo: () => unawaited(tasks.update(id, content)),
);
}
}
@override
Widget build(BuildContext context) => DragTarget<Object>(
onWillAcceptWithDetails: (details) =>
details.data is NewTaskDrag || details.data is TaskDrag,
onAcceptWithDetails: (details) => switch (details.data) {
TaskDrag(:final id) => unawaited(_move(context, id)),
TaskDrag(:final id) => unawaited(moveTask(context, id, day)),
_ => unawaited(showQuickAdd(context, projectId: projectId, day: day)),
},
// Laid over the rows beside them, not around them: the rows paint on

View file

@ -9,6 +9,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/day_grid.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/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
@ -30,8 +31,11 @@ const _gutter = 56.0;
/// below the day's hours with each timed task a block as long as it lasts
/// (half an hour at least), overlapping ones side by side, coloured by
/// [coloring]. A long press on a free half hour marks it and adds a task
/// there for half an hour; a block tapped opens its task. *Overdue* opens
/// what is overdue, with Reschedule.
/// there for half an hour; a block tapped opens its task, and held and
/// dragged moves it to another of the days shown and another time, its top
/// snapping to a quarter hour, or onto a day's *All day*, taking its time
/// away ([moveTask], which can be undone). *Overdue* opens what is overdue,
/// with Reschedule.
class const ThreeDayGrid({
required final ProjectCalendar Function(int days) calendarFor,
required final TaskColoring coloring,
@ -45,6 +49,9 @@ class const ThreeDayGrid({
/// The key of [day]'s column of hours.
static Key columnKey(Day day) => ValueKey('grid-day-$day');
/// The key of [day]'s tasks without a time.
static Key allDayKey(Day day) => ValueKey('grid-all-day-$day');
/// The key of the button opening the overdue tasks.
static const overdueKey = ValueKey('grid-overdue');
@ -148,11 +155,9 @@ class _ThreeDayGridState() extends State<ThreeDayGrid> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DayName(day: day, today: today, locale: locale),
ConstrainedBox(
constraints: const BoxConstraints(
minHeight: 32,
maxHeight: 96,
),
_AllDayDropArea(
key: ThreeDayGrid.allDayKey(day),
day: day,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -348,10 +353,44 @@ class _DayColumnState() extends State<_DayColumn> {
}
}
/// Where a block dragged over the column would start, in minutes after
/// midnight.
int? _hover;
/// Where a block dragged by [drag] would start with the pointer at
/// [position], across the screen: its top a quarter hour, all of it on
/// the day.
int _startAt(TaskDrag drag, Offset position) {
final box = context.findRenderObject()! as RenderBox;
final top = box.globalToLocal(position).dy - drag.grabY;
final quarters = (top / _hourHeight * 4).round();
return quarters.clamp(0, 24 * 4 - 1) * 15;
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final line = scheme.outlineVariant;
return DragTarget<TaskDrag>(
onMove: (details) {
final start = _startAt(details.data, details.offset);
if (start != _hover) {
setState(() => _hover = start);
}
},
onLeave: (_) => setState(() => _hover = null),
onAcceptWithDetails: (details) {
final start = _startAt(details.data, details.offset);
setState(() => _hover = null);
unawaited(moveTask(context, details.data.id, widget.day, time: start));
},
builder: (context, _, _) => _hours(theme, line),
);
}
Widget _hours(ThemeData theme, Color line) {
final scheme = theme.colorScheme;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) => unawaited(_add(details.localPosition.dy)),
@ -385,6 +424,31 @@ class _DayColumnState() extends State<_DayColumn> {
),
),
),
if (_hover case final hover?)
Positioned(
top: top(hover),
height: _hourHeight / 2,
left: 2,
right: 2,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
border: Border.all(color: scheme.primary),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
timeText(context, hover),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
),
),
),
),
),
),
for (final block in widget.blocks)
Positioned(
top: top(block.start) + 1,
@ -409,9 +473,121 @@ class _DayColumnState() extends State<_DayColumn> {
}
}
/// A day's tasks without a time, where a block dropped goes without its
/// time; tinted while one is over it.
class const _AllDayDropArea({
required final Day day,
required final Widget child,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => DragTarget<TaskDrag>(
onAcceptWithDetails: (details) =>
unawaited(moveTask(context, details.data.id, day, allDay: true)),
builder: (context, candidates, _) => DecoratedBox(
decoration: BoxDecoration(
color: candidates.isEmpty
? null
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32, maxHeight: 96),
child: child,
),
),
);
}
/// A block held and dragged (D17): it follows the pointer where it was
/// grabbed, its place meanwhile faded, the grid scrolling at its edges.
class const _DraggableBlock({
required final String taskId,
required final Widget child,
}) extends StatefulWidget {
@override
State<_DraggableBlock> createState() => _DraggableBlockState();
}
class _DraggableBlockState() extends State<_DraggableBlock> {
late final _drag = TaskDrag(widget.taskId);
var _grab = Offset.zero;
/// One for each scrollable the block is in, innermost first.
var _scrollers = <EdgeDraggingAutoScroller>[];
void _stop() {
for (final scroller in _scrollers) {
scroller.stopAutoScroll();
}
_scrollers = [];
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => Listener(
onPointerDown: (event) {
_grab = event.localPosition;
_drag.grabY = event.localPosition.dy;
},
child: LongPressDraggable<TaskDrag>(
data: _drag,
dragAnchorStrategy: pointerDragAnchorStrategy,
onDragStarted: () {
_scrollers = [
for (
var scrollable = Scrollable.maybeOf(context);
scrollable != null;
scrollable = Scrollable.maybeOf(scrollable.context)
)
EdgeDraggingAutoScroller(scrollable, velocityScalar: 20),
];
},
onDragUpdate: (details) {
for (final scroller in _scrollers) {
scroller.startAutoScrollIfNecessary(
Rect.fromCenter(
center: details.globalPosition,
width: 1,
height: 64,
),
);
}
},
onDragEnd: (_) => _stop(),
onDraggableCanceled: (_, _) => _stop(),
onDragCompleted: _stop,
// Built as the drag starts, once the grab is known.
feedback: Builder(
builder: (context) => Transform.translate(
offset: -_grab,
child: Material(
elevation: 6,
type: MaterialType.transparency,
child: SizedBox.fromSize(
size: constraints.biggest,
child: widget.child,
),
),
),
),
childWhenDragging: Opacity(opacity: 0.4, child: widget.child),
child: widget.child,
),
),
);
}
/// A task's block, coloured by [coloring] (its priority, first label with a
/// colour, or project's), struck through once done, muted with the
/// recurrence's mark for a later time; a tap opens the task.
/// recurrence's mark for a later time; a tap opens the task, a long press
/// drags it, but for a later time.
class const _TaskBlock({
required final CalendarEntry entry,
required final TaskColoring coloring,
@ -448,7 +624,7 @@ class const _TaskBlock({
color: muted ? scheme.onSurfaceVariant : null,
decoration: content.completed ? TextDecoration.lineThrough : null,
);
return Material(
final block = Material(
color: Color.alphaBlend(
color.withValues(alpha: muted ? 0.08 : 0.18),
scheme.surface,
@ -488,5 +664,8 @@ class const _TaskBlock({
),
),
);
return entry.occurrence
? block
: _DraggableBlock(taskId: entry.task.id, child: block);
}
}

View file

@ -810,6 +810,140 @@ void main() {
expect(date.duration, const Duration(minutes: 30));
});
group('a block held and dragged', () {
/// Holds [from], moves to [to] in steps and lets go.
Future<void> drag(WidgetTester tester, Offset from, Offset to) async {
final gesture = await tester.startGesture(from);
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();
}
/// Tomorrow's 9:00 dentist, an hour long, weekly, in the 3-day grid;
/// its ID.
Future<String> dentist(WidgetTester tester) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Dentist',
projectId: inbox(),
date: TaskDate(
today.plus(1),
time: 9 * 60,
duration: const Duration(hours: 1),
repeat: const Repeat(RepeatUnit.week),
),
),
);
await threeDay(tester);
final stored = await tester.runAsync(() => tasks().all());
return stored!.single.id;
}
Future<TaskDate> dateOf(WidgetTester tester) async {
final stored = await tester.runAsync(() => tasks().all());
return stored!.single.content!.date!;
}
testWidgets('onto another day and hour moves its task there, its '
'duration and recurrence kept, the place under the pointer '
'snapping to a quarter hour; which can be undone', (tester) async {
final id = await dentist(tester);
final today = Day.of(DateTime.now());
final block = find.byKey(ThreeDayGrid.blockKey(id, today.plus(1)));
await tester.ensureVisible(block);
await tester.pumpAndSettle();
// Held 20 minutes below its top, dropped where 14:27 is under the
// pointer: its top at 14:07, which snaps to 14:00.
final from = tester.getTopLeft(block) + const Offset(10, 20);
final column = tester.getTopLeft(
find.byKey(ThreeDayGrid.columnKey(today)),
);
await drag(tester, from, column + const Offset(20, 14 * 60 + 27));
expect(
find.byKey(quickAddTitleKey),
findsNothing,
reason: 'the hold a drag, not a new task',
);
final moved = await dateOf(tester);
expect(moved.day, today);
expect(moved.time, 14 * 60);
expect(moved.duration, const Duration(hours: 1));
expect(moved.repeat, const Repeat(RepeatUnit.week));
expect(find.byKey(ThreeDayGrid.blockKey(id, today)), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
final back = await dateOf(tester);
expect(back.day, today.plus(1));
expect(back.time, 9 * 60);
});
testWidgets('onto a day’s All day takes its time away', (tester) async {
final id = await dentist(tester);
final today = Day.of(DateTime.now());
final block = find.byKey(ThreeDayGrid.blockKey(id, today.plus(1)));
await tester.ensureVisible(block);
await tester.pumpAndSettle();
final from = tester.getTopLeft(block) + const Offset(10, 20);
final allDay = tester.getCenter(
find.byKey(ThreeDayGrid.allDayKey(today.plus(2))),
);
await drag(tester, from, allDay);
final moved = await dateOf(tester);
expect(moved.day, today.plus(2));
expect(moved.time, isNull);
expect(moved.duration, isNull);
expect(moved.repeat, const Repeat(RepeatUnit.week));
});
testWidgets('a later time of a repeating task stays where it is', (
tester,
) async {
final id = await dentist(tester);
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(SwitchListTile, 'Future occurrences'),
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
await tester.pumpAndSettle();
// Today's grid shows tomorrow and the day after; the next time is
// a week on.
await tester.tap(find.byTooltip('Later days'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Later days'));
await tester.pumpAndSettle();
final today = Day.of(DateTime.now());
final later = find.byKey(ThreeDayGrid.blockKey(id, today.plus(8)));
await tester.ensureVisible(later);
await tester.pumpAndSettle();
final column = tester.getTopLeft(
find.byKey(ThreeDayGrid.columnKey(today.plus(6))),
);
await drag(
tester,
tester.getTopLeft(later) + const Offset(10, 20),
column + const Offset(20, 14 * 60),
);
final date = await dateOf(tester);
expect(date.day, today.plus(1));
expect(date.time, 9 * 60);
});
});
testWidgets('its filters keep the agenda to what they ask for; Reset '
'all puts it back', (tester) async {
await start(tester, usedBefore: true);