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
13 changed files with 903 additions and 92 deletions

View file

@ -165,5 +165,7 @@
"tasksMenu": "Mehr",
"quickAddMore": "Mehr",
"quickAddTokenHint": "Tippe ein hervorgehobenes Wort an, um es als Text zu behalten.",
"deadlineTitle": "Frist"
"deadlineTitle": "Frist",
"orderChanged": "Reihenfolge geändert",
"addTaskCancel": "Hier ablegen zum Abbrechen"
}

View file

@ -449,5 +449,13 @@
"deadlineTitle": "Deadline",
"@deadlineTitle": {
"description": "Quick add's deadline chip and menu entry (D15)."
},
"orderChanged": "Order changed",
"@orderChanged": {
"description": "Snack bar after a task was dragged into another place (ADR 0033, D16)."
},
"addTaskCancel": "Drop here to cancel",
"@addTaskCancel": {
"description": "The × left in the add button's place while it is dragged (D16)."
}
}

View file

@ -1095,6 +1095,18 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Deadline'**
String get deadlineTitle;
/// Snack bar after a task was dragged into another place (ADR 0033, D16).
///
/// In en, this message translates to:
/// **'Order changed'**
String get orderChanged;
/// The × left in the add button's place while it is dragged (D16).
///
/// In en, this message translates to:
/// **'Drop here to cancel'**
String get addTaskCancel;
}
class _AppLocalizationsDelegate

View file

@ -707,4 +707,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get deadlineTitle => 'Frist';
@override
String get orderChanged => 'Reihenfolge geändert';
@override
String get addTaskCancel => 'Hier ablegen zum Abbrechen';
}

View file

@ -706,6 +706,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get deadlineTitle => 'Deadline';
@override
String get orderChanged => 'Order changed';
@override
String get addTaskCancel => 'Drop here to cancel';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -7,6 +7,7 @@ import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
@ -8,6 +10,7 @@ import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.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_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -19,7 +22,10 @@ import 'package:material_ui/material_ui.dart';
/// [completed]. Sub-tasks show in their task's sheet, counted on its row.
/// A section's heading stays atop the list while its tasks scroll beneath
/// it, until the next one pushes it away (the developer's, 1 Nov, D14);
/// the rows are built as they scroll into view.
/// the rows are built as they scroll into view. A row held is dragged into
/// another place, or a little to the right to become the sub-task of the
/// row it is dropped below, and the add button dropped between two rows
/// adds a task there (D16).
class const ProjectTasks({
required final String projectId,
final bool completed = false,
@ -45,18 +51,84 @@ class const ProjectTasks({
),
);
}
return CustomScrollView(
slivers: [
SliverList.list(
children: [for (final task in loose) TaskTile(task: task)],
),
for (final section in sections) _section(context, state, section),
SliverPadding(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
// The groups in their order: the tasks in no section, then each
// section's; a drop goes between two rows of one (D16).
final groups = [
(sectionId: null, tasks: loose),
for (final section in sections)
(
sectionId: section.id,
tasks: tasksIn(
state.tasks,
projectId,
sectionId: section.id,
completed: completed,
),
),
],
];
final last = groups.last;
return NewTaskDropArea(
// Below every row: last in the last group.
onDrop: () => unawaited(
dropAt(
context,
const NewTaskDrag(),
projectId,
DropSpot(
sectionId: last.sectionId,
after: last.tasks.lastOrNull?.content.position,
),
),
),
child: CustomScrollView(
slivers: [
SliverList.list(
children: [
for (final (index, task) in loose.indexed)
_row(state, groups.first, index, task),
],
),
for (final (index, section) in sections.indexed)
_section(context, state, section, groups[index], groups[index + 1]),
SliverPadding(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
),
],
),
);
}
/// [task], the [index]th of [group], dragged and dropped on (D16).
Widget _row(TodoState state, _Group group, int index, OpenTask task) {
final tasks = group.tasks;
final subTasks = subTasksOf(state.tasks, task.id);
return TaskDropTarget(
key: ValueKey('drop-${task.id}'),
projectId: projectId,
excluded: task.id,
above: DropSpot(
sectionId: group.sectionId,
after: index > 0 ? tasks[index - 1].content.position : null,
before: task.content.position,
),
below: DropSpot(
sectionId: group.sectionId,
after: task.content.position,
before: index + 1 < tasks.length
? tasks[index + 1].content.position
: null,
),
under: DropSpot(
sectionId: group.sectionId,
parentId: task.id,
after: subTasks.lastOrNull?.content.position,
),
child: DraggableTask(
task: task,
child: TaskTile(task: task),
),
);
}
@ -64,16 +136,13 @@ class const ProjectTasks({
BuildContext context,
TodoState state,
StoredSection section,
_Group previous,
_Group group,
) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final content = section.content!;
final tasks = tasksIn(
state.tasks,
projectId,
sectionId: section.id,
completed: completed,
);
final tasks = group.tasks;
final heading = ListTile(
title: Text(
'${content.name} ${tasks.where((t) => !t.content.completed).length}',
@ -138,17 +207,37 @@ class const ProjectTasks({
const SliverToBoxAdapter(child: Divider(height: 24)),
PinnedHeaderSliver(
// Opaque, so the rows scrolling beneath don't show through.
child: ColoredBox(color: theme.colorScheme.surface, child: heading),
// Above its middle, the end of the group before; below, this
// section's start (D16).
child: TaskDropTarget(
projectId: projectId,
above: DropSpot(
sectionId: previous.sectionId,
after: previous.tasks.lastOrNull?.content.position,
),
below: DropSpot(
sectionId: section.id,
before: tasks.firstOrNull?.content.position,
),
child: ColoredBox(
color: theme.colorScheme.surface,
child: heading,
),
),
),
SliverList.builder(
itemCount: tasks.length,
itemBuilder: (context, index) => TaskTile(task: tasks[index]),
itemBuilder: (context, index) =>
_row(state, group, index, tasks[index]),
),
],
);
}
}
/// A group of a project's list: the tasks of a section, or of none.
typedef _Group = ({String? sectionId, List<OpenTask> tasks});
enum _SectionAction() {
add,
edit,
@ -175,16 +264,3 @@ Future<void> addSection(BuildContext context, String projectId) async {
/// Quick add into [projectId], above its sections.
Future<void> showQuickAddIn(BuildContext context, String projectId) =>
showQuickAdd(context, projectId: projectId);
/// The button adding a task, above the navigation bar (ADR 0033, D2).
class const AddTaskButton({required final VoidCallback onPressed, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => FloatingActionButton(
// Every tab keeps its own, alive beside the others: no hero flies.
heroTag: null,
tooltip: AppLocalizations.of(context).taskAddTitle,
onPressed: onPressed,
child: const Icon(LucideIcons.plus),
);
}

View file

@ -114,6 +114,43 @@ class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
}
}
/// Places the task [id] at [position] in [sectionId] of its project,
/// under [parentId] where given (dragged there, the developer's, 1 Nov,
/// ADR 0033, D16), its sub-tasks, theirs too, into that section with it;
/// returns what it changed, as it was, for [restore] to undo. A task is
/// never placed under itself or under one of its sub-tasks.
Future<List<StoredTask>> place(
String id, {
required String? sectionId,
required double position,
String? parentId,
}) async {
final tasks = await all();
final moving = _withDescendants({id}, tasks);
if (parentId != null && moving.contains(parentId)) {
throw ArgumentError.value(parentId, 'parentId', 'is $id or under it');
}
final before = <StoredTask>[];
for (final task in tasks) {
final content = task.content;
if (content == null || !moving.contains(task.id)) {
continue;
}
final placed = task.id == id
? content.copyWith(
sectionId: () => sectionId,
parentId: () => parentId,
position: position,
)
: content.copyWith(sectionId: () => sectionId);
if (placed != content) {
before.add(task);
await update(task.id, placed);
}
}
return before;
}
/// Copies the task [id] with its sub-tasks, theirs too, each as it is,
/// the copy right after it among its siblings, and returns the copy's ID
/// (the developer's, 1 Nov: the task's menu's Duplicate, as Todoist's).

View file

@ -12,6 +12,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_parser.dar
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -22,7 +23,9 @@ import 'package:material_ui/material_ui.dart';
/// priority, project and labels, and a description where asked for. It
/// goes into [projectId] (the Inbox where none is given), [sectionId] and
/// under [parentId] where given, dated [day] where given, last among its
/// siblings. The sheet stays for the next task; going back closes it.
/// siblings, or at [spot] where the add button was dropped (D16), each
/// next one below the one before. The sheet stays for the next task; going
/// back closes it.
///
/// It shows the title alone at first, its chips once typing starts, and its
/// description from its + or the down arrow (the developer's, 1 Nov, D15).
@ -37,6 +40,7 @@ Future<void> showQuickAdd(
String? sectionId,
String? parentId,
Day? day,
DropSpot? spot,
}) => showAdaptiveSheet<void>(
context,
builder: (context) => _QuickAdd(
@ -44,6 +48,7 @@ Future<void> showQuickAdd(
sectionId: sectionId,
parentId: parentId,
day: day,
spot: spot,
),
);
@ -55,6 +60,7 @@ class const _QuickAdd({
required final String? sectionId,
required final String? parentId,
required final Day? day,
required final DropSpot? spot,
}) extends StatefulWidget {
@override
State<_QuickAdd> createState() => _QuickAddState();
@ -76,6 +82,9 @@ class _QuickAddState() extends State<_QuickAdd> {
Day? _deadline;
var _describing = false;
/// Where the add button was dropped, moving on below each task added.
late DropSpot? _spot = widget.spot;
/// The tokens tapped off, by their text in lower case.
final _ignored = <String>{};
@ -200,6 +209,16 @@ class _QuickAddState() extends State<_QuickAdd> {
_ => position,
},
);
// At the drop's place while the task stays where it was dropped.
final spot = _spot;
final position =
spot != null &&
sectionId == spot.sectionId &&
projectId == widget.projectId
? spot.position
: siblings.isEmpty
? 0.0
: last + 1;
try {
final made = <String>[
for (final (index, name) in parsed.newLabels.indexed)
@ -214,7 +233,7 @@ class _QuickAddState() extends State<_QuickAdd> {
sectionId: sectionId,
parentId: widget.parentId,
description: _description.text.trim(),
position: siblings.isEmpty ? 0 : last + 1,
position: position,
date: parsed.date ?? _date,
deadline: parsed.deadline ?? _deadline,
priority: parsed.priority ?? _priority,
@ -241,6 +260,14 @@ class _QuickAddState() extends State<_QuickAdd> {
_labelIds = [];
_deadline = null;
_ignored.clear();
if (spot != null) {
_spot = DropSpot(
sectionId: spot.sectionId,
parentId: spot.parentId,
after: position,
before: spot.before,
);
}
});
_focus.requestFocus();
}

View file

@ -0,0 +1,347 @@
import 'dart:async';
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/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_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A task's row being dragged (ADR 0033, D16), and where the pointer went
/// down, so that how far it moved right says whether it is to become a
/// sub-task.
final class TaskDrag(final String id) {
/// Where the pointer went down, across the screen.
double startX = 0;
}
/// The add button being dragged onto a list (D16).
final class const NewTaskDrag();
/// How far right a dragged row goes to become the sub-task of the row it
/// is dropped below.
const indentReach = 48.0;
/// Where a task dropped in a project's list goes: into [sectionId], under
/// [parentId] where given, between the siblings at [after] and [before].
final class const DropSpot({
required final String? sectionId,
final String? parentId,
final double? after,
final double? before,
}) {
/// The position between [after] and [before].
double get position => switch ((after, before)) {
(null, null) => 0,
(final after?, null) => after + 1,
(null, final before?) => before - 1,
(final after?, final before?) => (after + before) / 2,
};
}
/// Drops what is dragged at [spot] in [projectId]: a task placed there with
/// its sub-tasks, which can be undone for a moment; the add button opens
/// quick add there.
Future<void> dropAt(
BuildContext context,
Object data,
String projectId,
DropSpot spot,
) async {
switch (data) {
case NewTaskDrag():
await showQuickAdd(
context,
projectId: projectId,
sectionId: spot.sectionId,
parentId: spot.parentId,
spot: spot,
);
case TaskDrag(:final id):
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final tasks = PersonScope.of<TodoPerson>(context).tasks;
try {
final before = await tasks.place(
id,
sectionId: spot.sectionId,
parentId: spot.parentId,
position: spot.position,
);
if (before.isNotEmpty && context.mounted) {
showUndoSnackBar(
context,
message: l10n.orderChanged,
onUndo: () => unawaited(tasks.restore(before)),
);
}
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
}
}
}
/// Where over a row a drop lands.
enum _Where() {
above,
below,
under,
}
/// A row of a project's list a task or the add button can be dropped on
/// (D16): its upper half puts it at [above], its lower half at [below],
/// and a task dragged [indentReach] to the right there at [under], the
/// row's sub-task, where given. A line shows where, indented for a
/// sub-task. The row [excluded] (the one dragged) takes no drop.
class const TaskDropTarget({
required final String projectId,
required final DropSpot above,
required final DropSpot below,
required final Widget child,
final DropSpot? under,
final String? excluded,
super.key,
}) extends StatefulWidget {
@override
State<TaskDropTarget> createState() => _TaskDropTargetState();
}
class _TaskDropTargetState() extends State<TaskDropTarget> {
_Where? _hover;
_Where _where(DragTargetDetails<Object> details) {
final box = context.findRenderObject()! as RenderBox;
final local = box.globalToLocal(details.offset);
if (local.dy < box.size.height / 2) {
return _Where.above;
}
if (details.data case TaskDrag(:final startX)
when widget.under != null &&
details.offset.dx - startX >= indentReach) {
return _Where.under;
}
return _Where.below;
}
void _show(_Where? where) {
if (where != _hover) {
setState(() => _hover = where);
}
}
@override
Widget build(BuildContext context) {
final line = Theme.of(context).colorScheme.primary;
return DragTarget<Object>(
onWillAcceptWithDetails: (details) => switch (details.data) {
NewTaskDrag() => true,
TaskDrag(:final id) => id != widget.excluded,
_ => false,
},
onMove: (details) => _show(_where(details)),
onLeave: (_) => _show(null),
onAcceptWithDetails: (details) {
final where = _where(details);
_show(null);
final spot = switch (where) {
_Where.above => widget.above,
_Where.below => widget.below,
_Where.under => widget.under!,
};
unawaited(dropAt(context, details.data, widget.projectId, spot));
},
builder: (context, _, _) => Stack(
children: [
widget.child,
if (_hover case final where?)
Positioned(
left: where == _Where.under ? 56 : 16,
right: 16,
top: where == _Where.above ? 0 : null,
bottom: where == _Where.above ? null : 0,
child: IgnorePointer(
child: Container(
key: const ValueKey('drop-line'),
height: 2,
color: line,
),
),
),
],
),
);
}
}
/// A task's row dragged after a long press (D16), into another place in
/// its project, or a little to the right to become a sub-task, the list
/// scrolling at its edges; not while tasks are being chosen.
class const DraggableTask({
required final OpenTask task,
required final Widget child,
super.key,
}) extends StatefulWidget {
@override
State<DraggableTask> createState() => _DraggableTaskState();
}
class _DraggableTaskState() extends State<DraggableTask> {
late final _drag = TaskDrag(widget.task.id);
EdgeDraggingAutoScroller? _scroller;
void _stop() {
_scroller?.stopAutoScroll();
_scroller = null;
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final selection = TaskSelectionScope.maybeOf(context);
if (selection != null && selection.active) {
return widget.child;
}
final theme = Theme.of(context);
return Listener(
onPointerDown: (event) => _drag.startX = event.position.dx,
child: LongPressDraggable<TaskDrag>(
data: _drag,
dragAnchorStrategy: pointerDragAnchorStrategy,
onDragStarted: () {
if (Scrollable.maybeOf(context) case final scrollable?) {
_scroller = EdgeDraggingAutoScroller(
scrollable,
velocityScalar: 20,
);
}
},
onDragUpdate: (details) => _scroller?.startAutoScrollIfNecessary(
Rect.fromCenter(
center: details.globalPosition,
width: 1,
height: 64,
),
),
onDragEnd: (_) => _stop(),
onDraggableCanceled: (_, _) => _stop(),
onDragCompleted: _stop,
feedback: Transform.translate(
offset: const Offset(-24, -28),
child: Material(
elevation: 6,
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
child: Text(
widget.task.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyLarge,
),
),
),
),
),
childWhenDragging: Opacity(opacity: 0.4, child: widget.child),
child: widget.child,
),
);
}
}
/// 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({
required final VoidCallback onDrop,
required final Widget child,
super.key,
}) extends StatelessWidget {
@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,
),
);
}
/// The button adding a task, above the navigation bar (ADR 0033, D2); it
/// can be dragged onto the list to add the task there (D16), its place
/// meanwhile a larger × where dropping it gives up.
class const AddTaskButton({required final VoidCallback onPressed, super.key})
extends StatelessWidget {
/// The key of the × left in the button's place while it is dragged.
static const cancelKey = ValueKey('add-task-cancel');
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Draggable<NewTaskDrag>(
data: const NewTaskDrag(),
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: Transform.translate(
offset: const Offset(-28, -28),
child: Material(
elevation: 8,
shape: const CircleBorder(),
color: scheme.primaryContainer,
child: SizedBox.square(
dimension: 56,
child: Icon(LucideIcons.plus, color: scheme.onPrimaryContainer),
),
),
),
childWhenDragging: DragTarget<NewTaskDrag>(
// Taken, to do nothing: the drag given up.
onAcceptWithDetails: (_) {},
builder: (context, candidates, _) => Semantics(
label: l10n.addTaskCancel,
child: AnimatedContainer(
key: cancelKey,
duration: const Duration(milliseconds: 120),
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: candidates.isEmpty
? scheme.surfaceContainerHighest
: scheme.errorContainer,
),
child: Icon(
LucideIcons.x,
color: candidates.isEmpty
? scheme.onSurfaceVariant
: scheme.onErrorContainer,
),
),
),
),
child: FloatingActionButton(
// Every tab keeps its own, alive beside the others: no hero flies.
heroTag: null,
tooltip: l10n.taskAddTitle,
onPressed: onPressed,
child: const Icon(LucideIcons.plus),
),
);
}
}

View file

@ -26,8 +26,9 @@ import 'package:material_ui/material_ui.dart';
/// undone for a moment.
///
/// Where [selectable] and tasks can be chosen (D14, as Todoist's), the row
/// swiped right or held is chosen, a tap then choosing it or letting it go,
/// and swiped left asks for its date.
/// swiped right is chosen, a tap then choosing it or letting it go, and
/// swiped left asks for its date; held, it is dragged where its list lets
/// it be (D16).
class const TaskTile({
required final OpenTask task,
final bool showProject = false,
@ -182,7 +183,6 @@ class const TaskTile({
onTap: selection != null && selection.active
? () => selection.toggle(task.id)
: () => openTaskSheet(context, task.id),
onLongPress: selection == null ? null : () => selection.toggle(task.id),
);
if (selection == null) {
return tile;

View file

@ -3,12 +3,12 @@ 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/feature/project/widget/project_tasks.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/date_picker.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_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -37,36 +37,40 @@ class const TodayTab({super.key}) extends StatelessWidget {
children: [
const KeysResetBanner(),
Expanded(
child: !state.loaded
? const Center(child: AppLoadingIndicator())
: overdue.isEmpty && due.isEmpty
? Center(
child: EmptyListMessage(
icon: LucideIcons.calendarCheck,
title: l10n.todayEmptyTitle,
hint: l10n.todayEmptyHint,
),
)
: ListView(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
children: [
if (overdue.isNotEmpty)
DayHeading(
title: l10n.overdue,
action: TextButton(
onPressed: () => reschedule(context, overdue),
child: Text(l10n.reschedule),
// The add button dropped on the list adds a task for today (D16).
child: NewTaskDropArea(
onDrop: () => showQuickAdd(context, day: today),
child: !state.loaded
? const Center(child: AppLoadingIndicator())
: overdue.isEmpty && due.isEmpty
? Center(
child: EmptyListMessage(
icon: LucideIcons.calendarCheck,
title: l10n.todayEmptyTitle,
hint: l10n.todayEmptyHint,
),
)
: ListView(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
children: [
if (overdue.isNotEmpty)
DayHeading(
title: l10n.overdue,
action: TextButton(
onPressed: () => reschedule(context, overdue),
child: Text(l10n.reschedule),
),
),
),
for (final task in overdue)
TaskTile(task: task, showProject: true),
DayHeading(title: dayHeading(context, today, today)),
for (final task in due)
TaskTile(task: task, showProject: true),
],
),
for (final task in overdue)
TaskTile(task: task, showProject: true),
DayHeading(title: dayHeading(context, today, today)),
for (final task in due)
TaskTile(task: task, showProject: true),
],
),
),
),
],
),

View file

@ -4,11 +4,11 @@ import 'dart:math';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.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_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
@ -160,23 +160,32 @@ class _UpcomingTabState() extends State<UpcomingTab> {
for (final task in overdue)
TaskTile(task: task, showProject: true),
],
for (final (day, tasks) in days) ...[
DayHeading(
// The add button dropped on a day adds a task
// for it (D16).
for (final (day, tasks) in days)
NewTaskDropArea(
key: _keyOf(day),
title: dayHeading(context, day, today),
quiet: tasks.isEmpty,
action: IconButton(
tooltip: l10n.taskAddOn(
dayHeading(context, day, today),
),
icon: const Icon(LucideIcons.plus),
onPressed: () =>
showQuickAdd(context, day: day),
onDrop: () => showQuickAdd(context, day: day),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DayHeading(
title: dayHeading(context, day, today),
quiet: tasks.isEmpty,
action: IconButton(
tooltip: l10n.taskAddOn(
dayHeading(context, day, today),
),
icon: const Icon(LucideIcons.plus),
onPressed: () =>
showQuickAdd(context, day: day),
),
),
for (final task in tasks)
TaskTile(task: task, showProject: true),
],
),
),
for (final task in tasks)
TaskTile(task: task, showProject: true),
],
],
),
),