feat(todo): a project laid out as a board, its columns its sections (ADR 0033, D17)

The Display's new Layout picks List or Board. The board has a column
for the tasks in no section, then one a section with its menu, each
task a card and each column ending with Add task, a last column adding
a section; grouped otherwise, a column a group. Narrower than a medium
width it pages one column at a time with dots below; from it on the
columns stand side by side. Cards drag within and between columns as
rows do (D16), every scrollable they are in scrolling at its edges.

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 18:51:15 +01:00
commit e4fbfb54d1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 714 additions and 111 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`). - **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 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, shown as its Display sets (D17: `ProjectView` in the project's sealed payload, `DisplayButton` and `showDisplaySheet` in `display_sheet.dart`; completed tasks, grouping, sorting, filters, applied by `ProjectTasks` with `groupTasks`), 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 +, projects and labels suggested as `#` and `@` are typed; 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, onto Today's `NewTaskDropArea`, or onto a day of Upcoming, `DayDropArea`, opening quick add there; Upcoming's rows dragged onto a day move to it), 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 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, shown as its Display sets (D17: `ProjectView` in the project's sealed payload, `DisplayButton` and `showDisplaySheet` in `display_sheet.dart`; its layout, completed tasks, grouping, sorting, filters, applied by `ProjectTasks` with `groupTasks`; laid out as a board, `ProjectBoard`, `project_board.dart`: a column a section, paged below a medium width, side by side from it), 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 +, projects and labels suggested as `#` and `@` are typed; 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, onto Today's `NewTaskDropArea`, or onto a day of Upcoming, `DayDropArea`, opening quick add there; Upcoming's rows dragged onto a day move to it), 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. - **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. - **`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

@ -202,5 +202,9 @@
"viewNothingHint": "Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.", "viewNothingHint": "Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.",
"displaySave": "Speichern", "displaySave": "Speichern",
"displayCancel": "Abbrechen", "displayCancel": "Abbrechen",
"groupNoLabel": "Ohne Label" "groupNoLabel": "Ohne Label",
"layoutTitle": "Layout",
"layoutList": "Liste",
"layoutBoard": "Board",
"boardNoSection": "(Kein Abschnitt)"
} }

View file

@ -608,5 +608,21 @@
"groupNoLabel": "No label", "groupNoLabel": "No label",
"@groupNoLabel": { "@groupNoLabel": {
"description": "A project's group of tasks without a label (ADR 0033, D17)." "description": "A project's group of tasks without a label (ADR 0033, D17)."
},
"layoutTitle": "Layout",
"@layoutTitle": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"layoutList": "List",
"@layoutList": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"layoutBoard": "Board",
"@layoutBoard": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
},
"boardNoSection": "(No section)",
"@boardNoSection": {
"description": "A project's layout in its Display, and its board's columns (ADR 0033, D17)."
} }
} }

View file

@ -1317,6 +1317,30 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'No label'** /// **'No label'**
String get groupNoLabel; String get groupNoLabel;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Layout'**
String get layoutTitle;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'List'**
String get layoutList;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Board'**
String get layoutBoard;
/// A project's layout in its Display, and its board's columns (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'(No section)'**
String get boardNoSection;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -821,4 +821,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get groupNoLabel => 'Ohne Label'; String get groupNoLabel => 'Ohne Label';
@override
String get layoutTitle => 'Layout';
@override
String get layoutList => 'Liste';
@override
String get layoutBoard => 'Board';
@override
String get boardNoSection => '(Kein Abschnitt)';
} }

View file

@ -820,6 +820,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get groupNoLabel => 'No label'; String get groupNoLabel => 'No label';
@override
String get layoutTitle => 'Layout';
@override
String get layoutList => 'List';
@override
String get layoutBoard => 'Board';
@override
String get boardNoSection => '(No section)';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -98,6 +98,35 @@ class const _DisplaySheet({required final String projectId})
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
heading(l10n.layoutTitle),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SegmentedButton<ProjectLayout>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: ProjectLayout.list,
icon: const Icon(LucideIcons.list),
label: Text(l10n.layoutList),
),
ButtonSegment(
value: ProjectLayout.board,
icon: const Icon(LucideIcons.squareKanban),
label: Text(l10n.layoutBoard),
),
],
// The calendar comes with its own step (D17); until then a
// project laid out so shows as a list.
selected: {
if (view.layout == ProjectLayout.board)
ProjectLayout.board
else
ProjectLayout.list,
},
onSelectionChanged: (picked) =>
_set(context, view.copyWith(layout: picked.single)),
),
),
SwitchListTile( SwitchListTile(
secondary: const Icon(LucideIcons.circleCheck), secondary: const Icon(LucideIcons.circleCheck),
title: Text(l10n.displayCompleted), title: Text(l10n.displayCompleted),

View file

@ -0,0 +1,264 @@
import 'dart:async';
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/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';
import 'package:material_ui/material_ui.dart';
/// A column of a project's board: its heading, the section it shows (none:
/// the tasks in no section, or a group, D17), and its tasks in their order.
typedef BoardColumn = ({
String title,
StoredSection? section,
String? sectionId,
List<OpenTask> tasks,
});
/// A project's tasks as a board (ADR 0033, D17, as Todoist's): a column a
/// section, the tasks in none first, each task a card; on a phone one
/// column at a time, paged, the next peeking in and dots below saying
/// where; from a medium width on side by side, scrolled across. Where
/// [reorder], cards are dragged within and between columns, the board
/// scrolling at its edges; a column takes a card or the add button dropped
/// below its cards, last in it. Each column ends with Add task; after them,
/// where [addSection], a column adds a section.
class const ProjectBoard({
required final String projectId,
required final List<BoardColumn> columns,
required final bool reorder,
required final bool addSection,
super.key,
}) extends StatefulWidget {
/// The key of the dots below the paged columns.
static const dotsKey = ValueKey('board-dots');
@override
State<ProjectBoard> createState() => _ProjectBoardState();
}
class _ProjectBoardState() extends State<ProjectBoard> {
final _pages = PageController(viewportFraction: 0.88);
@override
void dispose() {
_pages.dispose();
super.dispose();
}
/// Settles on the nearest column once a drag has scrolled the pages
/// partway.
void _snap() {
if (!_pages.hasClients) {
return;
}
if (_pages.page case final page? when (page - page.round()).abs() > 0.01) {
_pages.animateToPage(
page.round(),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final count = widget.columns.length + (widget.addSection ? 1 : 0);
Widget column(int index) => index < widget.columns.length
? _Column(
projectId: widget.projectId,
column: widget.columns[index],
reorder: widget.reorder,
)
: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 16),
child: OutlinedButton.icon(
icon: const Icon(LucideIcons.listPlus),
label: Text(l10n.sectionAdd),
onPressed: () => addSection(context, widget.projectId),
),
),
);
return LayoutBuilder(
builder: (context, constraints) =>
WindowSize.fromWidth(constraints.maxWidth).isAtLeastMedium
? _wide(count, column)
: _paged(context, count, column),
);
}
Widget _wide(int count, Widget Function(int index) column) =>
ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: count,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (context, index) =>
SizedBox(width: 320, child: column(index)),
);
Widget _paged(
BuildContext context,
int count,
Widget Function(int index) column,
) {
return Column(
children: [
Expanded(
child: Listener(
onPointerUp: (_) => _snap(),
child: PageView.builder(
controller: _pages,
padEnds: false,
itemCount: count,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(left: 12),
child: column(index),
),
),
),
),
ListenableBuilder(
listenable: _pages,
builder: (context, _) {
final current = _pages.hasClients
? (_pages.page ?? 0).round()
: _pages.initialPage;
final scheme = Theme.of(context).colorScheme;
return Padding(
key: ProjectBoard.dotsKey,
padding: EdgeInsets.only(
top: 8,
bottom: 8 + FloatingNavigationBar.coverOf(context),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var index = 0; index < count; index++)
Container(
width: 8,
height: 8,
margin: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: index == current
? scheme.onSurface
: scheme.onSurface.withValues(alpha: 0.3),
),
),
],
),
);
},
),
],
);
}
}
class const _Column({
required final String projectId,
required final BoardColumn column,
required final bool reorder,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final tasks = column.tasks;
final section = column.section;
// A drop goes last in the column, below the task placed last.
final last = tasks.isEmpty
? null
: tasks.map((t) => t.content.position).reduce((a, b) => a > b ? a : b);
Widget card(int index) {
final tile = Card.outlined(
margin: const EdgeInsets.symmetric(vertical: 4),
clipBehavior: Clip.antiAlias,
child: TaskTile(task: tasks[index]),
);
return reorder
? placedTaskRow(
state: state,
projectId: projectId,
sectionId: column.sectionId,
tasks: tasks,
index: index,
child: tile,
)
: tile;
}
return DragTarget<Object>(
onWillAcceptWithDetails: (details) =>
details.data is NewTaskDrag || (reorder && details.data is TaskDrag),
onAcceptWithDetails: (details) => unawaited(
dropAt(
context,
details.data,
projectId,
DropSpot(sectionId: column.sectionId, after: last),
),
),
builder: (context, candidates, _) => DecoratedBox(
decoration: BoxDecoration(
color: candidates.isEmpty
? Colors.transparent
: theme.colorScheme.primary.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
contentPadding: const EdgeInsets.only(left: 4),
title: Text(
'${column.title} '
'${tasks.where((t) => !t.content.completed).length}',
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: switch (section) {
final section? => SectionMenu(
projectId: projectId,
section: section,
),
null => null,
},
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 96),
children: [
for (var index = 0; index < tasks.length; index++)
card(index),
TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.taskAddTitle),
onPressed: () => showQuickAdd(
context,
projectId: projectId,
sectionId: column.sectionId,
),
),
],
),
),
],
),
),
);
}
}

View file

@ -7,6 +7,7 @@ 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/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart'; import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart'; import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart'; 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/bloc/todo_cubit.dart';
@ -83,6 +84,9 @@ class const ProjectTasks({required final String projectId, super.key})
for (final section in sections) for (final section in sections)
(sectionId: section.id, tasks: kept(sectionId: section.id)), (sectionId: section.id, tasks: kept(sectionId: section.id)),
]; ];
if (view.layout == ProjectLayout.board) {
return _board(context, state, view, sections, groups, today);
}
if (view.grouping != TaskGrouping.none) { if (view.grouping != TaskGrouping.none) {
return _grouped( return _grouped(
context, context,
@ -141,6 +145,64 @@ class const ProjectTasks({required final String projectId, super.key})
); );
} }
/// The project's tasks as a board (D17): a column for the tasks in no
/// section and one a section, or, grouped otherwise, one a group.
Widget _board(
BuildContext context,
TodoState state,
ProjectView view,
List<StoredSection> sections,
List<_Group> groups,
Day today,
) {
final l10n = AppLocalizations.of(context);
if (view.grouping == TaskGrouping.none) {
return ProjectBoard(
projectId: projectId,
reorder: view.canReorder,
addSection: true,
columns: [
(
title: l10n.boardNoSection,
section: null,
sectionId: null,
tasks: groups.first.tasks,
),
for (final (index, section) in sections.indexed)
(
title: section.content!.name,
section: section,
sectionId: section.id,
tasks: groups[index + 1].tasks,
),
],
);
}
final labels = {
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
};
return ProjectBoard(
projectId: projectId,
reorder: false,
addSection: false,
columns: [
for (final group in groupTasks(
[for (final group in groups) ...group.tasks]..sort(view.compare),
view.grouping,
today: today,
labelOrder: [for (final label in state.labels) label.id],
))
(
title: groupTitle(context, group.key, view.grouping, labels, today),
section: null,
sectionId: null,
tasks: group.tasks,
),
],
);
}
/// [tasks] grouped by [view]'s grouping (D17), each group under a heading /// [tasks] grouped by [view]'s grouping (D17), each group under a heading
/// that stays atop the list while its rows scroll beneath it; nothing /// that stays atop the list while its rows scroll beneath it; nothing
/// dragged, the order being the grouping's. /// dragged, the order being the grouping's.
@ -166,17 +228,8 @@ class const ProjectTasks({required final String projectId, super.key})
for (final label in state.labels) for (final label in state.labels)
if (label.content case final content?) label.id: content.name, if (label.content case final content?) label.id: content.name,
}; };
String title(TaskGroupKey key) => switch (key) { String title(TaskGroupKey key) =>
OverdueGroup() => l10n.overdue, groupTitle(context, key, view.grouping, labels, today);
DayGroup(:final day?) => dayHeading(context, day, today),
DayGroup(day: null) => switch (view.grouping) {
TaskGrouping.deadline => l10n.rangeNoDeadline,
_ => l10n.rangeNoDate,
},
PriorityGroup(:final priority) => priorityText(l10n, priority),
LabelGroup(:final labelId?) => labels[labelId] ?? '',
LabelGroup(labelId: null) => l10n.groupNoLabel,
};
final groups = groupTasks( final groups = groupTasks(
tasks, tasks,
view.grouping, view.grouping,
@ -224,39 +277,16 @@ class const ProjectTasks({required final String projectId, super.key})
int index, int index,
OpenTask task, { OpenTask task, {
required bool reorder, required bool reorder,
}) { }) => reorder
if (!reorder) { ? placedTaskRow(
return TaskTile(task: task); state: state,
} projectId: projectId,
final tasks = group.tasks; sectionId: group.sectionId,
final subTasks = subTasksOf(state.tasks, task.id); tasks: group.tasks,
return TaskDropTarget( index: index,
key: ValueKey('drop-${task.id}'), child: TaskTile(task: group.tasks[index]),
projectId: projectId, )
excluded: task.id, : TaskTile(task: task);
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),
),
);
}
Widget _section( Widget _section(
BuildContext context, BuildContext context,
@ -266,7 +296,6 @@ class const ProjectTasks({required final String projectId, super.key})
_Group group, { _Group group, {
required bool reorder, required bool reorder,
}) { }) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final content = section.content!; final content = section.content!;
final tasks = group.tasks; final tasks = group.tasks;
@ -282,52 +311,7 @@ class const ProjectTasks({required final String projectId, super.key})
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
trailing: PopupMenuButton<_SectionAction>( trailing: SectionMenu(projectId: projectId, section: section),
tooltip: l10n.sectionMenu,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) async {
final todo = PersonScope.of<TodoPerson>(context);
switch (action) {
case _SectionAction.add:
await showQuickAdd(
context,
projectId: projectId,
sectionId: section.id,
);
case _SectionAction.edit:
if (await editSection(context, content) case final edited?) {
await todo.sections.update(section.id, edited);
}
case _SectionAction.delete:
if (await confirmDestructiveAction(
context,
title: l10n.sectionDeleteTitle,
message: l10n.sectionDeleteMessage(content.name),
confirmLabel: l10n.sectionDelete,
)) {
await todo.deleteSection(projectId, section.id);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _SectionAction.add,
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.sectionDelete,
destructive: true,
),
),
],
),
); );
return SliverMainAxisGroup( return SliverMainAxisGroup(
slivers: [ slivers: [
@ -362,6 +346,127 @@ class const ProjectTasks({required final String projectId, super.key})
} }
} }
/// A section's menu (D8): a task added there, the section renamed or
/// deleted with its tasks, which asks first.
class const SectionMenu({
required final String projectId,
required final StoredSection section,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final content = section.content!;
return PopupMenuButton<_SectionAction>(
tooltip: l10n.sectionMenu,
icon: const Icon(LucideIcons.ellipsisVertical),
onSelected: (action) async {
final todo = PersonScope.of<TodoPerson>(context);
switch (action) {
case _SectionAction.add:
await showQuickAdd(
context,
projectId: projectId,
sectionId: section.id,
);
case _SectionAction.edit:
if (await editSection(context, content) case final edited?) {
await todo.sections.update(section.id, edited);
}
case _SectionAction.delete:
if (await confirmDestructiveAction(
context,
title: l10n.sectionDeleteTitle,
message: l10n.sectionDeleteMessage(content.name),
confirmLabel: l10n.sectionDelete,
)) {
await todo.deleteSection(projectId, section.id);
}
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: _SectionAction.add,
child: MenuEntry(LucideIcons.plus, l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: MenuEntry(LucideIcons.pencil, l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: MenuEntry(
LucideIcons.trash2,
l10n.sectionDelete,
destructive: true,
),
),
],
);
}
}
/// The [index]th of [tasks], those of [sectionId] in [projectId] in their
/// order, as [child], held to be dragged into another place and taking a
/// drop above it, below it, or under it as its sub-task (D16).
Widget placedTaskRow({
required TodoState state,
required String projectId,
required String? sectionId,
required List<OpenTask> tasks,
required int index,
required Widget child,
}) {
final task = tasks[index];
final subTasks = subTasksOf(state.tasks, task.id);
return TaskDropTarget(
key: ValueKey('drop-${task.id}'),
projectId: projectId,
excluded: task.id,
above: DropSpot(
sectionId: sectionId,
after: index > 0 ? tasks[index - 1].content.position : null,
before: task.content.position,
),
below: DropSpot(
sectionId: sectionId,
after: task.content.position,
before: index + 1 < tasks.length
? tasks[index + 1].content.position
: null,
),
under: DropSpot(
sectionId: sectionId,
parentId: task.id,
after: subTasks.lastOrNull?.content.position,
),
child: DraggableTask(task: task, child: child),
);
}
/// The heading of the group [key] of tasks grouped by [grouping] on
/// [today] (D17), [labels] naming the labels by their IDs.
String groupTitle(
BuildContext context,
TaskGroupKey key,
TaskGrouping grouping,
Map<String, String> labels,
Day today,
) {
final l10n = AppLocalizations.of(context);
return switch (key) {
OverdueGroup() => l10n.overdue,
DayGroup(:final day?) => dayHeading(context, day, today),
DayGroup(day: null) => switch (grouping) {
TaskGrouping.deadline => l10n.rangeNoDeadline,
_ => l10n.rangeNoDate,
},
PriorityGroup(:final priority) => priorityText(l10n, priority),
LabelGroup(:final labelId?) => labels[labelId] ?? '',
LabelGroup(labelId: null) => l10n.groupNoLabel,
};
}
/// A group of a project's list: the tasks of a section, or of none. /// A group of a project's list: the tasks of a section, or of none.
typedef _Group = ({String? sectionId, List<OpenTask> tasks}); typedef _Group = ({String? sectionId, List<OpenTask> tasks});

View file

@ -185,7 +185,8 @@ class _TaskDropTargetState() extends State<TaskDropTarget> {
/// A task's row dragged after a long press (D16), into another place in /// 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 /// 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. /// scrolling at its edges, and a board's columns too at theirs (D17); not
/// while tasks are being chosen.
class const DraggableTask({ class const DraggableTask({
required final OpenTask task, required final OpenTask task,
required final Widget child, required final Widget child,
@ -197,11 +198,15 @@ class const DraggableTask({
class _DraggableTaskState() extends State<DraggableTask> { class _DraggableTaskState() extends State<DraggableTask> {
late final _drag = TaskDrag(widget.task.id); late final _drag = TaskDrag(widget.task.id);
EdgeDraggingAutoScroller? _scroller;
/// One for each scrollable the row is in, innermost first.
var _scrollers = <EdgeDraggingAutoScroller>[];
void _stop() { void _stop() {
_scroller?.stopAutoScroll(); for (final scroller in _scrollers) {
_scroller = null; scroller.stopAutoScroll();
}
_scrollers = [];
} }
@override @override
@ -223,20 +228,26 @@ class _DraggableTaskState() extends State<DraggableTask> {
data: _drag, data: _drag,
dragAnchorStrategy: pointerDragAnchorStrategy, dragAnchorStrategy: pointerDragAnchorStrategy,
onDragStarted: () { onDragStarted: () {
if (Scrollable.maybeOf(context) case final scrollable?) { _scrollers = [
_scroller = EdgeDraggingAutoScroller( for (
scrollable, var scrollable = Scrollable.maybeOf(context);
velocityScalar: 20, scrollable != null;
scrollable = Scrollable.maybeOf(scrollable.context)
)
EdgeDraggingAutoScroller(scrollable, velocityScalar: 20),
];
},
onDragUpdate: (details) {
for (final scroller in _scrollers) {
scroller.startAutoScrollIfNecessary(
Rect.fromCenter(
center: details.globalPosition,
width: 1,
height: 64,
),
); );
} }
}, },
onDragUpdate: (details) => _scroller?.startAutoScrollIfNecessary(
Rect.fromCenter(
center: details.globalPosition,
width: 1,
height: 64,
),
),
onDragEnd: (_) => _stop(), onDragEnd: (_) => _stop(),
onDraggableCanceled: (_, _) => _stop(), onDraggableCanceled: (_, _) => _stop(),
onDragCompleted: _stop, onDragCompleted: _stop,

View file

@ -12,6 +12,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart'; import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart'; import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_board.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart'; import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart'; import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart'; import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
@ -495,6 +496,122 @@ void main() {
await done(tester); await done(tester);
expect(find.byType(DraggableTask), findsNWidgets(3)); expect(find.byType(DraggableTask), findsNWidgets(3));
}); });
group('Board', () {
/// The Inbox as a board, with a section "Errands" holding "Stamps".
Future<String> boardOf(WidgetTester tester) async {
await inboxOf(tester);
final errands = await tester.runAsync(
() => SectionRepository(
sectionRows,
keys,
).add(SectionContent(name: 'Errands', projectId: inbox())),
);
sectionRows.bringRemoteChanges();
await seed(
tester,
TaskContent(
title: 'Stamps',
projectId: inbox(),
sectionId: errands,
),
);
await display(tester);
await tester.tap(find.text('Board'));
await tester.pumpAndSettle();
await done(tester);
return errands!;
}
testWidgets('a column for the tasks in no section and one a section, '
'paged on a phone with dots below; kept in the project', (
tester,
) async {
await boardOf(tester);
expect(find.text('(No section) 3'), findsOneWidget);
expect(find.byKey(ProjectBoard.dotsKey), findsOneWidget);
final projects = await tester.runAsync(
() => ProjectRepository(projectRows, keys).all(),
);
expect(
projects!
.singleWhere((project) => project.id == inbox())
.content
?.view
.layout,
ProjectLayout.board,
);
await tester.fling(
find.byType(PageView),
const Offset(-300, 0),
1000,
);
await tester.pumpAndSettle();
expect(find.text('Errands 1'), findsOneWidget);
expect(find.text('Stamps').hitTestable(), findsOneWidget);
});
testWidgets("a column's Add task adds into its section", (
tester,
) async {
final errands = await boardOf(tester);
await tester.fling(
find.byType(PageView),
const Offset(-300, 0),
1000,
);
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(TextButton, 'Add task').hitTestable(),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(quickAddTitleKey), 'Envelopes');
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Add'));
await tester.pumpAndSettle();
final all = await tester.runAsync(() => tasks().all());
expect(
all!
.singleWhere((task) => task.content?.title == 'Envelopes')
.content
?.sectionId,
errands,
);
});
testWidgets('on a wide window the columns stand side by side, and a '
'card dragged onto another column goes last in it', (
tester,
) async {
final errands = await boardOf(tester);
tester.view.physicalSize = const Size(1000, 900);
await tester.pumpAndSettle();
expect(find.byType(PageView), findsNothing);
expect(find.text('Errands 1'), findsOneWidget);
final from = tester.getCenter(row('Apple'));
final gesture = await tester.startGesture(from);
await tester.pump(
kLongPressTimeout + const Duration(milliseconds: 50),
);
final to = tester.getBottomLeft(row('Stamps')) + const Offset(40, 80);
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();
final all = await tester.runAsync(() => tasks().all());
final apple = all!.singleWhere((t) => t.content?.title == 'Apple');
expect(apple.content?.sectionId, errands);
expect(find.text('Errands 2'), findsOneWidget);
});
});
}); });
group('quick add reads what is typed (D15)', () { group('quick add reads what is typed (D15)', () {

View file

@ -260,9 +260,18 @@ alternative was put to them, the choice is theirs.
such a project, and renaming it there drops its Display, which beats a newer schema such a project, and renaming it there drops its Display, which beats a newer schema
it couldn't read at all. Today's and Upcoming's Display come with Upcoming's 3-day it couldn't read at all. Today's and Upcoming's Display come with Upcoming's 3-day
grid, in a sealed person-settings row (`todo_settings`, one migration), which also grid, in a sealed person-settings row (`todo_settings`, one migration), which also
holds D4's settings. **To come, in this order**: Board (a column a section, paged on holds D4's settings. **The Board** (as built, 1 Nov), the Display's Layout (List,
phones, side by side on wide windows, rows dragged between columns), Calendar for Board): a column for the tasks in no section, *(No section)*, always first, then one
projects (the week strip and agenda), Upcoming's 3-day grid. a section with its menu, each heading counting its open tasks, each task a card,
each column ending with Add task, a last column adding a section; grouped otherwise,
a column a group, nothing dragged. Below a medium width (the board's own, not the
window's, so the pane beside the tabs counts) one column at a time, paged, the next
peeking in, settling on the nearest when let go, dots below saying where; from it on,
columns 320 wide side by side, scrolled across. Cards are dragged as rows (D16),
within and between columns, every scrollable they are in scrolling at its edges; a
column takes a card or the add button dropped below its cards, last in it. **To come,
in this order**: Calendar for projects (the week strip and agenda; the Layout's third
choice, until then shown as a list), Upcoming's 3-day grid.
## Steps ## Steps