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:
parent
7d1de304c8
commit
e4fbfb54d1
12 changed files with 714 additions and 111 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)', () {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue