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:
parent
4960234a63
commit
36f1e9a25c
13 changed files with 903 additions and 92 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue