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

View file

@ -4,6 +4,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`), and its recurrence (`Repeat`, ADR 0033, D7: `TaskRepository.complete` moves a recurring task on, reopens its sub-tasks and returns what it changed for `restore` to undo; picked through `pickRepeat`) and its reminders, minutes before its time (D5: `planReminders` plans them, `ReminderScheduler` under the person's app hands them to `ReminderGateway`, `SystemReminderGateway` on `flutter_local_notifications`, the system scheduling them but on Linux, where the running app shows them; `TodoPerson.dispose` cancels them; Android's manifest declares the boot receiver and exact alarms, the build desugars). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Inbox, Today (where it opens), Upcoming and Browse (D12), (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description, deadline; its chips once typing starts, the rest behind its +; what is typed read by `parseQuickAdd`, `lib/src/feature/task/model/quick_add_parser.dart`, in `QuickAddWords`' tables of the app's language and English, a table per language, D15) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2; a dialog on the desktop, D11), where the task is on top (`_TaskPath`, its steps opening in place) and its ⋮ menu in place of a close button (when it was added, `addedAt` from its UUID v7; Duplicate, `TaskRepository.duplicate`; its done sub-tasks hidden, `hideDoneSubTasks`; Delete; D13), menus' entries with their icons (`MenuEntry`, D14), sections' headings pinned while their tasks scroll (`ProjectTasks`, slivers), tasks chosen together (`TaskSelection` held by `TodoShell` in `TaskSelectionScope`; a row swiped right or held, or *Select tasks* in a ⋮; `TabScreen` then counts them and shows `TaskSelectionBar` in the navigation bar's place; a row swiped left asks for its date; `TaskTile(selectable: false)` in a sheet's sub-tasks and search), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it, its month unfolding in place, a way back to today), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Inbox, Today (where it opens), Upcoming and Browse (D12), (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description, deadline; its chips once typing starts, the rest behind its +; what is typed read by `parseQuickAdd`, `lib/src/feature/task/model/quick_add_parser.dart`, in `QuickAddWords`' tables of the app's language and English, a table per language, D15) and its sheet (`openTaskSheet`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2; a dialog on the desktop, D11), where the task is on top (`_TaskPath`, its steps opening in place) and its ⋮ menu in place of a close button (when it was added, `addedAt` from its UUID v7; Duplicate, `TaskRepository.duplicate`; its done sub-tasks hidden, `hideDoneSubTasks`; Delete; D13), menus' entries with their icons (`MenuEntry`, D14), sections' headings pinned while their tasks scroll (`ProjectTasks`, slivers), dragging (D16, `task_drag.dart`: a project's rows `DraggableTask` in `TaskDropTarget`s giving each half its `DropSpot`, `TaskRepository.place`; `AddTaskButton` dragged onto those, or onto Today's and Upcoming's `NewTaskDropArea`s, opening quick add there), tasks chosen together (`TaskSelection` held by `TodoShell` in `TaskSelectionScope`; a row swiped right, or *Select tasks* in a ⋮; `TabScreen` then counts them and shows `TaskSelectionBar` in the navigation bar's place; a row swiped left asks for its date; `TaskTile(selectable: false)` in a sheet's sub-tasks and search), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it, its month unfolding in place, a way back to today), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart` on Linux, `device: linux`, as Todo has no web build); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.

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),
],
],
),
),

View file

@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kLongPressTimeout;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
@ -14,6 +15,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart'
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.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/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -1070,7 +1072,7 @@ void main() {
'their mind; Complete can be undone', (tester) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants', 'Post letter']);
await tester.longPress(row('Water plants'));
await tester.drag(row('Water plants'), const Offset(500, 0));
await tester.pumpAndSettle();
await tester.tap(row('Post letter'));
await tester.pumpAndSettle();
@ -1097,18 +1099,18 @@ void main() {
expect(find.text('Post letter'), findsOneWidget);
});
testWidgets("a row held is chosen in the Inbox too, which a project's "
testWidgets("a row swiped is chosen in the Inbox too, which a project's "
'menu also starts', (tester) async {
await start(tester, usedBefore: true);
await seedToday(tester, ['Water plants']);
await tester.longPress(row('Water plants'));
await tester.drag(row('Water plants'), const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.text('1 task selected'), findsOneWidget);
await tester.tap(find.byTooltip('Stop selecting'));
await tester.pumpAndSettle();
await goTab(tester, 'Inbox');
await tester.longPress(row('Water plants'));
await tester.drag(row('Water plants'), const Offset(500, 0));
await tester.pumpAndSettle();
expect(find.text('1 task selected'), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
@ -1123,6 +1125,212 @@ void main() {
});
});
group('dragging (D16)', () {
Finder row(String title) => find.widgetWithText(ListTile, title);
Future<Map<String, TaskContent>> stored(WidgetTester tester) async {
final all = await tester.runAsync(() => tasks().all());
return {for (final task in all!) task.content!.title: task.content!};
}
/// The Inbox holding [titles] in their order.
Future<void> inboxOf(WidgetTester tester, List<String> titles) async {
await start(tester, usedBefore: true);
for (final (index, title) in titles.indexed) {
await seed(
tester,
TaskContent(
title: title,
projectId: inbox(),
position: index.toDouble(),
),
);
}
await goTab(tester, 'Inbox');
}
/// Moves a pressed pointer from [from] to [to] in steps, held first
/// where [hold], and lets go.
Future<void> dragFrom(
WidgetTester tester,
Offset from,
Offset to, {
bool hold = true,
}) async {
final gesture = await tester.startGesture(from);
if (hold) {
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();
}
List<String> order(WidgetTester tester, List<String> titles) =>
[...titles]..sort(
(a, b) => tester
.getCenter(row(a))
.dy
.compareTo(tester.getCenter(row(b)).dy),
);
testWidgets('a row held and dropped on the upper half of another goes '
'before it, which can be undone', (tester) async {
await inboxOf(tester, ['Alpha', 'Bravo', 'Charlie']);
await dragFrom(
tester,
tester.getCenter(row('Charlie')),
tester.getTopLeft(row('Alpha')) + const Offset(100, 6),
);
expect(order(tester, ['Alpha', 'Bravo', 'Charlie']), [
'Charlie',
'Alpha',
'Bravo',
]);
final after = await stored(tester);
expect(after['Charlie']!.position, lessThan(after['Alpha']!.position));
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(order(tester, ['Alpha', 'Bravo', 'Charlie']), [
'Alpha',
'Bravo',
'Charlie',
]);
});
testWidgets('dropped below another a little to the right, a row becomes '
'its sub-task; straight down, it stays a task beside it', (
tester,
) async {
await inboxOf(tester, ['Alpha', 'Bravo', 'Charlie']);
// Straight: after Bravo, still a task of its own.
await dragFrom(
tester,
tester.getCenter(row('Alpha')),
tester.getBottomLeft(row('Bravo')) +
Offset(tester.getCenter(row('Alpha')).dx, -6),
);
expect((await stored(tester))['Alpha']!.parentId, isNull);
expect(order(tester, ['Alpha', 'Bravo', 'Charlie']), [
'Bravo',
'Alpha',
'Charlie',
]);
final from = tester.getCenter(row('Charlie'));
await dragFrom(
tester,
from,
Offset(from.dx + 80, tester.getBottomLeft(row('Bravo')).dy - 6),
);
final after = await stored(tester);
expect(after['Charlie']!.parentId, isNotNull);
final bravo = (await tester.runAsync(() => tasks().all()))!
.singleWhere((task) => task.content!.title == 'Bravo');
expect(after['Charlie']!.parentId, bravo.id);
expect(row('Charlie'), findsNothing, reason: "in Bravo's sheet now");
expect(find.text('0/1'), findsOneWidget);
});
testWidgets("dropped on a section's heading, a row goes first in it", (
tester,
) async {
await inboxOf(tester, ['Alpha']);
await tester.runAsync(
() => SectionRepository(
sectionRows,
keys,
).add(SectionContent(name: 'Errands', projectId: inbox())),
);
sectionRows.bringRemoteChanges();
await tester.pumpAndSettle();
final heading = find.widgetWithText(ListTile, 'Errands 0');
await dragFrom(
tester,
tester.getCenter(row('Alpha')),
tester.getBottomLeft(heading) + const Offset(100, -6),
);
final sections = await tester.runAsync(
() => SectionRepository(sectionRows, keys).all(),
);
expect(
(await stored(tester))['Alpha']!.sectionId,
sections!.single.id,
);
});
testWidgets('the add button dropped below a row adds the task there; '
'dropped back on its place, an ×, nothing', (tester) async {
await inboxOf(tester, ['Alpha', 'Bravo']);
final button = tester.getCenter(find.byTooltip('Add task'));
await dragFrom(
tester,
button,
tester.getBottomLeft(row('Alpha')) + const Offset(100, -6),
hold: false,
);
await tester.enterText(find.byKey(quickAddTitleKey), 'Between');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Close').last);
await tester.pumpAndSettle();
expect(order(tester, ['Alpha', 'Bravo', 'Between']), [
'Alpha',
'Between',
'Bravo',
]);
final gesture = await tester.startGesture(button);
final away = button - const Offset(0, 200);
for (final (from, to) in [(button, away), (away, button)]) {
for (var step = 1; step <= 5; step++) {
await gesture.moveTo(Offset.lerp(from, to, step / 5)!);
await tester.pump();
}
expect(find.byKey(AddTaskButton.cancelKey), findsOneWidget);
}
await gesture.up();
await tester.pumpAndSettle();
expect(find.byKey(quickAddTitleKey), findsNothing);
});
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);
await start(tester, usedBefore: true);
await goTab(tester, 'Upcoming');
final button = tester.getCenter(find.byTooltip('Add task'));
final day = find.byTooltip(RegExp('^Add a task on .*Tomorrow'));
expect(day, findsOneWidget);
await dragFrom(
tester,
button,
tester.getCenter(day) - const Offset(200, 0),
hold: false,
);
await tester.enterText(find.byKey(quickAddTitleKey), 'Later');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
expect((await stored(tester))['Later']!.date?.day, tomorrow);
});
});
testWidgets('Upcoming lists the days ahead, the empty ones too', (
tester,
) async {

View file

@ -177,6 +177,62 @@ void main() {
},
);
test('a task placed elsewhere in its project takes its sub-tasks into its '
'section; under another task it becomes its sub-task; restoring what '
'it returns undoes it', () async {
final tasks = await device(alice);
final pack = await tasks.add(
const TaskContent(title: 'Pack', projectId: 'inbox', position: 1),
);
final socks = await tasks.add(
TaskContent(title: 'Socks', projectId: 'inbox', parentId: pack),
);
final trip = await tasks.add(
const TaskContent(title: 'Trip', projectId: 'inbox', position: 2),
);
final before = await tasks.place(pack, sectionId: 's', position: 0.5);
var all = {for (final t in await tasks.all()) t.id: t.content!};
expect(all[pack]!.sectionId, 's');
expect(all[pack]!.position, 0.5);
expect(all[pack]!.parentId, isNull);
expect(all[socks]!.sectionId, 's', reason: 'its sub-task along');
expect(all[socks]!.parentId, pack);
expect(all[trip]!.sectionId, isNull, reason: 'the others stay');
await tasks.place(trip, sectionId: 's', parentId: pack, position: 3);
all = {for (final t in await tasks.all()) t.id: t.content!};
expect(all[trip]!.parentId, pack);
expect(all[trip]!.sectionId, 's');
await tasks.restore(before);
all = {for (final t in await tasks.all()) t.id: t.content!};
expect(all[pack]!.sectionId, isNull);
expect(all[pack]!.position, 1);
expect(all[socks]!.sectionId, isNull);
});
test('a task is never placed under itself or its own sub-task', () async {
final tasks = await device(alice);
final pack = await tasks.add(
const TaskContent(title: 'Pack', projectId: 'inbox'),
);
final socks = await tasks.add(
TaskContent(title: 'Socks', projectId: 'inbox', parentId: pack),
);
expect(
() => tasks.place(pack, sectionId: null, parentId: socks, position: 0),
throwsArgumentError,
);
expect(
() => tasks.place(pack, sectionId: null, parentId: pack, position: 0),
throwsArgumentError,
);
final all = {for (final t in await tasks.all()) t.id: t.content!};
expect(all[pack]!.parentId, isNull, reason: 'nothing written');
});
test('moving tasks to a project takes their sub-tasks along, out of any '
'section, and a sub-task moved without its parent becomes a task of '
'its own', () async {