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:
parent
51ebed41cd
commit
0373f723e7
4 changed files with 399 additions and 49 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue