feat(todo): Upcoming's tasks dragged onto another day (ADR 0033, D16)

A row of Upcoming, overdue ones too, held and dropped on another day
moves there, its time, duration and recurrence kept; Moved to … offers
Undo. The tint over a day a drag hovers is laid beside the rows, not
around them, which paint on the Material beneath.

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:58:06 +01:00
commit c83f7c46ff
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 173 additions and 15 deletions

View file

@ -167,5 +167,6 @@
"quickAddTokenHint": "Tippe ein hervorgehobenes Wort an, um es als Text zu behalten.",
"deadlineTitle": "Frist",
"orderChanged": "Reihenfolge geändert",
"addTaskCancel": "Hier ablegen zum Abbrechen"
"addTaskCancel": "Hier ablegen zum Abbrechen",
"taskMovedTo": "Verschoben auf {day}"
}

View file

@ -457,5 +457,14 @@
"addTaskCancel": "Drop here to cancel",
"@addTaskCancel": {
"description": "The × left in the add button's place while it is dragged (D16)."
},
"taskMovedTo": "Moved to {day}",
"@taskMovedTo": {
"description": "Snack bar after a task was dragged onto another day in Upcoming (ADR 0033, D16).",
"placeholders": {
"day": {
"type": "String"
}
}
}
}

View file

@ -1107,6 +1107,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Drop here to cancel'**
String get addTaskCancel;
/// Snack bar after a task was dragged onto another day in Upcoming (ADR 0033, D16).
///
/// In en, this message translates to:
/// **'Moved to {day}'**
String taskMovedTo(String day);
}
class _AppLocalizationsDelegate

View file

@ -713,4 +713,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get addTaskCancel => 'Hier ablegen zum Abbrechen';
@override
String taskMovedTo(String day) {
return 'Verschoben auf $day';
}
}

View file

@ -712,6 +712,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get addTaskCancel => 'Drop here to cancel';
@override
String taskMovedTo(String day) {
return 'Moved to $day';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -1,12 +1,18 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -264,6 +270,76 @@ class _DraggableTaskState() extends State<DraggableTask> {
}
}
/// A day of Upcoming (D16): the add button dropped on it adds a task for
/// [day]; a task's row moves there, 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,
required final Widget child,
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)),
_ => unawaited(showQuickAdd(context, day: day)),
},
// Laid over the rows beside them, not around them: the rows paint on
// the Material beneath.
builder: (context, candidates, _) => Stack(
children: [
child,
if (candidates.isNotEmpty)
Positioned.fill(
child: IgnorePointer(
child: ColoredBox(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08),
),
),
),
],
),
);
}
/// A list's area the add button can be dropped on (D16), [onDrop] opening
/// quick add for it; tinted while the button is over it.
class const NewTaskDropArea({
@ -274,11 +350,22 @@ class const NewTaskDropArea({
@override
Widget build(BuildContext context) => DragTarget<NewTaskDrag>(
onAcceptWithDetails: (_) => onDrop(),
builder: (context, candidates, _) => ColoredBox(
color: candidates.isEmpty
? Colors.transparent
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.08),
child: child,
// Laid over the rows beside them, not around them: the rows paint on
// the Material beneath.
builder: (context, candidates, _) => Stack(
children: [
child,
if (candidates.isNotEmpty)
Positioned.fill(
child: IgnorePointer(
child: ColoredBox(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08),
),
),
),
],
),
);
}

View file

@ -158,14 +158,18 @@ class _UpcomingTabState() extends State<UpcomingTab> {
),
),
for (final task in overdue)
TaskTile(task: task, showProject: true),
DraggableTask(
task: task,
child: TaskTile(task: task, showProject: true),
),
],
// The add button dropped on a day adds a task
// for it (D16).
// for it, a row held and dropped there moves to
// it (D16).
for (final (day, tasks) in days)
NewTaskDropArea(
DayDropArea(
key: _keyOf(day),
onDrop: () => showQuickAdd(context, day: day),
day: day,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -182,7 +186,13 @@ class _UpcomingTabState() extends State<UpcomingTab> {
),
),
for (final task in tasks)
TaskTile(task: task, showProject: true),
DraggableTask(
task: task,
child: TaskTile(
task: task,
showProject: true,
),
),
],
),
),

View file

@ -1374,6 +1374,40 @@ void main() {
expect(find.byKey(quickAddTitleKey), findsNothing);
});
testWidgets('a row of Upcoming held and dropped on another day moves '
'there, its time kept, which can be undone', (tester) async {
final today = Day.of(DateTime.now());
await start(tester, usedBefore: true);
await seed(
tester,
TaskContent(
title: 'Water plants',
projectId: inbox(),
date: TaskDate(today, time: 23 * 60 + 30),
),
);
await goTab(tester, 'Upcoming');
final day = find.byTooltip(RegExp('^Add a task on .*Tomorrow'));
await dragFrom(
tester,
tester.getCenter(row('Water plants')),
tester.getCenter(day) - const Offset(200, 0),
);
expect(
(await stored(tester))['Water plants']!.date,
TaskDate(today.plus(1), time: 23 * 60 + 30),
);
expect(find.textContaining('Moved to'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(
(await stored(tester))['Water plants']!.date,
TaskDate(today, time: 23 * 60 + 30),
);
});
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);