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
5 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);

View file

@ -313,8 +313,16 @@ alternative was put to them, the choice is theirs.
above, timed ones as blocks as long as they last (half an hour at least, to midnight
at most), overlapping ones side by side in lanes (`layOutDay`); a long press on a free
half hour marks it and opens quick add there for half an hour; a block opens its task;
*Overdue* opens a sheet of the overdue tasks with Reschedule. **To come**: blocks
dragged to another time or day, a month grid on wide windows.
*Overdue* opens a sheet of the overdue tasks with Reschedule. A block held and
dragged (built 1 Nov 2026) moves its task to another of the three days and another
time, its top where it is dropped snapping to a quarter hour (the long press's half
hour is too coarse to move a block a little), 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, as a row dropped
on Upcoming's day (`moveTask`, shared with `DayDropArea`). A later time of a
repeating task doesn't move: only the task itself, at its next time, does. Dragging
past the three days shown waits until someone misses it. **To come**: a month grid
on wide windows.
## Steps