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
11 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`).
|
||||
- **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.
|
||||
- **`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.",
|
||||
"displaySave": "Speichern",
|
||||
"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": {
|
||||
"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:
|
||||
/// **'No label'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -821,4 +821,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -98,6 +98,35 @@ class const _DisplaySheet({required final String projectId})
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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(
|
||||
secondary: const Icon(LucideIcons.circleCheck),
|
||||
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/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/widget/project_board.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/task/bloc/todo_cubit.dart';
|
||||
|
|
@ -83,6 +84,9 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
for (final section in sections)
|
||||
(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) {
|
||||
return _grouped(
|
||||
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
|
||||
/// that stays atop the list while its rows scroll beneath it; nothing
|
||||
/// 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)
|
||||
if (label.content case final content?) label.id: content.name,
|
||||
};
|
||||
String title(TaskGroupKey key) => switch (key) {
|
||||
OverdueGroup() => l10n.overdue,
|
||||
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,
|
||||
};
|
||||
String title(TaskGroupKey key) =>
|
||||
groupTitle(context, key, view.grouping, labels, today);
|
||||
final groups = groupTasks(
|
||||
tasks,
|
||||
view.grouping,
|
||||
|
|
@ -224,39 +277,16 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
int index,
|
||||
OpenTask task, {
|
||||
required bool reorder,
|
||||
}) {
|
||||
if (!reorder) {
|
||||
return TaskTile(task: task);
|
||||
}
|
||||
final tasks = group.tasks;
|
||||
final subTasks = subTasksOf(state.tasks, task.id);
|
||||
return TaskDropTarget(
|
||||
key: ValueKey('drop-${task.id}'),
|
||||
projectId: projectId,
|
||||
excluded: task.id,
|
||||
above: DropSpot(
|
||||
sectionId: group.sectionId,
|
||||
after: index > 0 ? tasks[index - 1].content.position : null,
|
||||
before: task.content.position,
|
||||
),
|
||||
below: DropSpot(
|
||||
sectionId: group.sectionId,
|
||||
after: task.content.position,
|
||||
before: index + 1 < tasks.length
|
||||
? tasks[index + 1].content.position
|
||||
: null,
|
||||
),
|
||||
under: DropSpot(
|
||||
sectionId: group.sectionId,
|
||||
parentId: task.id,
|
||||
after: subTasks.lastOrNull?.content.position,
|
||||
),
|
||||
child: DraggableTask(
|
||||
task: task,
|
||||
child: TaskTile(task: task),
|
||||
),
|
||||
);
|
||||
}
|
||||
}) => reorder
|
||||
? placedTaskRow(
|
||||
state: state,
|
||||
projectId: projectId,
|
||||
sectionId: group.sectionId,
|
||||
tasks: group.tasks,
|
||||
index: index,
|
||||
child: TaskTile(task: group.tasks[index]),
|
||||
)
|
||||
: TaskTile(task: task);
|
||||
|
||||
Widget _section(
|
||||
BuildContext context,
|
||||
|
|
@ -266,7 +296,6 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
_Group group, {
|
||||
required bool reorder,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final content = section.content!;
|
||||
final tasks = group.tasks;
|
||||
|
|
@ -282,52 +311,7 @@ class const ProjectTasks({required final String projectId, super.key})
|
|||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: SectionMenu(projectId: projectId, section: section),
|
||||
);
|
||||
return SliverMainAxisGroup(
|
||||
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.
|
||||
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
|
||||
/// 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({
|
||||
required final OpenTask task,
|
||||
required final Widget child,
|
||||
|
|
@ -197,11 +198,15 @@ class const DraggableTask({
|
|||
|
||||
class _DraggableTaskState() extends State<DraggableTask> {
|
||||
late final _drag = TaskDrag(widget.task.id);
|
||||
EdgeDraggingAutoScroller? _scroller;
|
||||
|
||||
/// One for each scrollable the row is in, innermost first.
|
||||
var _scrollers = <EdgeDraggingAutoScroller>[];
|
||||
|
||||
void _stop() {
|
||||
_scroller?.stopAutoScroll();
|
||||
_scroller = null;
|
||||
for (final scroller in _scrollers) {
|
||||
scroller.stopAutoScroll();
|
||||
}
|
||||
_scrollers = [];
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -223,20 +228,26 @@ class _DraggableTaskState() extends State<DraggableTask> {
|
|||
data: _drag,
|
||||
dragAnchorStrategy: pointerDragAnchorStrategy,
|
||||
onDragStarted: () {
|
||||
if (Scrollable.maybeOf(context) case final scrollable?) {
|
||||
_scroller = EdgeDraggingAutoScroller(
|
||||
scrollable,
|
||||
velocityScalar: 20,
|
||||
_scrollers = [
|
||||
for (
|
||||
var scrollable = Scrollable.maybeOf(context);
|
||||
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(),
|
||||
onDraggableCanceled: (_, _) => _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/model/project.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/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
|
|
@ -495,6 +496,122 @@ void main() {
|
|||
await done(tester);
|
||||
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)', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue