feat(todo): each project's Display: completed tasks, grouping, sorting and filters, synced (ADR 0033, D17)

- A Display button beside a project's menu (the Inbox's too), a dot
  on it while the project shows otherwise than at first, opens its
  sheet: completed tasks, grouping (date, deadline, added date,
  priority, label), sorting (name, date, deadline, added date,
  priority), filters by date, deadline, priority and label, and
  Reset all.
- The choices live in the project's sealed payload (ProjectView,
  written only once set), so every device shows the project alike.
- Grouped, the groups stand in the sections' place under pinned
  headings; rows are dragged only while sorted by hand by sections.
- Show done tasks moves from the menu into Display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-01 18:41:12 +01:00
commit 7d1de304c8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 1878 additions and 81 deletions

View file

@ -4,6 +4,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`), and its recurrence (`Repeat`, ADR 0033, D7: `TaskRepository.complete` moves a recurring task on, reopens its sub-tasks and returns what it changed for `restore` to undo; picked through `pickRepeat`) and its reminders, minutes before its time (D5: `planReminders` plans them, `ReminderScheduler` under the person's app hands them to `ReminderGateway`, `SystemReminderGateway` on `flutter_local_notifications`, the system scheduling them but on Linux, where the running app shows them; `TodoPerson.dispose` cancels them; Android's manifest declares the boot receiver and exact alarms, the build desugars). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Inbox, Today (where it opens), Upcoming and Browse (D12), (`lib/src/tabs/`; ADR 0033, D8, since 1 Nov 2026): Today (overdue first with Reschedule, then today's), Upcoming (overdue, then the next 28 days, empty ones muted), Inbox and a project's screen (`ProjectScreen`, from Browse: its tasks above and in its sections, done ones shown on asking, sections added, renamed and deleted, the project edited and deleted), Browse (projects with their open counts, labels, what doesn't open counted); a task's row (`TaskTile`: its check in its priority's colour, its date in the colour of how near it is, deadline, sub-tasks done, labels), quick add (`showQuickAdd`: title, date, priority, project, labels, description, deadline; its chips once typing starts, the rest behind its +, 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`; 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 icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart` on Linux, `device: linux`, as Todo has no web build); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.

View file

@ -14,8 +14,6 @@
"settingsVersion": "Version {version} (Build {build})",
"clear": "Entfernen",
"colorLabel": "Farbe",
"completedShow": "Erledigte zeigen",
"completedHide": "Erledigte ausblenden",
"dateTitle": "Datum",
"dateNextWeek": "Nächste Woche",
"dateWeekend": "Dieses Wochenende",
@ -168,5 +166,41 @@
"deadlineTitle": "Frist",
"orderChanged": "Reihenfolge geändert",
"addTaskCancel": "Hier ablegen zum Abbrechen",
"taskMovedTo": "Verschoben auf {day}"
"taskMovedTo": "Verschoben auf {day}",
"display": "Anzeige",
"displayCompleted": "Erledigte Aufgaben",
"displaySort": "Sortieren",
"displayGrouping": "Gruppierung",
"displaySorting": "Sortierung",
"displayFilter": "Filter",
"displayDeadline": "Frist",
"displayLabel": "Label",
"displayReset": "Alles zurücksetzen",
"displayDone": "Fertig",
"displayAll": "Alle",
"displayAny": "Beliebig",
"groupingNone": "Keine",
"groupingDate": "Datum",
"groupingDeadline": "Frist",
"groupingAdded": "Hinzugefügt am",
"groupingPriority": "Priorität",
"groupingLabel": "Label",
"sortingManual": "Manuell",
"sortingName": "Name",
"sortingDate": "Datum",
"sortingDeadline": "Frist",
"sortingAdded": "Hinzugefügt am",
"sortingPriority": "Priorität",
"rangeToday": "Heute",
"rangeThisWeek": "Diese Woche",
"rangeNext7": "Nächste 7 Tage",
"rangeThisMonth": "Dieser Monat",
"rangeNext30": "Nächste 30 Tage",
"rangeNoDate": "Kein Datum",
"rangeNoDeadline": "Keine Frist",
"viewNothingTitle": "Nichts passt",
"viewNothingHint": "Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.",
"displaySave": "Speichern",
"displayCancel": "Abbrechen",
"groupNoLabel": "Ohne Label"
}

View file

@ -30,8 +30,6 @@
},
"clear": "Clear",
"colorLabel": "Colour",
"completedShow": "Show done tasks",
"completedHide": "Hide done tasks",
"dateTitle": "Date",
"dateNextWeek": "Next week",
"dateWeekend": "This weekend",
@ -466,5 +464,149 @@
"type": "String"
}
}
},
"display": "Display",
"@display": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayCompleted": "Completed tasks",
"@displayCompleted": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displaySort": "Sort",
"@displaySort": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayGrouping": "Grouping",
"@displayGrouping": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displaySorting": "Sorting",
"@displaySorting": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayFilter": "Filter",
"@displayFilter": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayDeadline": "Deadline",
"@displayDeadline": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayLabel": "Label",
"@displayLabel": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayReset": "Reset all",
"@displayReset": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayDone": "Done",
"@displayDone": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayAll": "All",
"@displayAll": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayAny": "Any",
"@displayAny": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"groupingNone": "None",
"@groupingNone": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"groupingDate": "Date",
"@groupingDate": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"groupingDeadline": "Deadline",
"@groupingDeadline": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"groupingAdded": "Added date",
"@groupingAdded": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"groupingPriority": "Priority",
"@groupingPriority": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"groupingLabel": "Label",
"@groupingLabel": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"sortingManual": "Manual",
"@sortingManual": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"sortingName": "Name",
"@sortingName": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"sortingDate": "Date",
"@sortingDate": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"sortingDeadline": "Deadline",
"@sortingDeadline": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"sortingAdded": "Added date",
"@sortingAdded": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"sortingPriority": "Priority",
"@sortingPriority": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"rangeToday": "Today",
"@rangeToday": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"rangeThisWeek": "This week",
"@rangeThisWeek": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"rangeNext7": "Next 7 days",
"@rangeNext7": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"rangeThisMonth": "This month",
"@rangeThisMonth": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"rangeNext30": "Next 30 days",
"@rangeNext30": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"rangeNoDate": "No date",
"@rangeNoDate": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"rangeNoDeadline": "No deadline",
"@rangeNoDeadline": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"viewNothingTitle": "Nothing matches",
"@viewNothingTitle": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"viewNothingHint": "This view's filters leave out every task here. Display changes them.",
"@viewNothingHint": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displaySave": "Save",
"@displaySave": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"displayCancel": "Cancel",
"@displayCancel": {
"description": "A project's Display sheet and what it shows (ADR 0033, D17)."
},
"groupNoLabel": "No label",
"@groupNoLabel": {
"description": "A project's group of tasks without a label (ADR 0033, D17)."
}
}

View file

@ -184,18 +184,6 @@ abstract class AppLocalizations {
/// **'Colour'**
String get colorLabel;
/// No description provided for @completedShow.
///
/// In en, this message translates to:
/// **'Show done tasks'**
String get completedShow;
/// No description provided for @completedHide.
///
/// In en, this message translates to:
/// **'Hide done tasks'**
String get completedHide;
/// No description provided for @dateTitle.
///
/// In en, this message translates to:
@ -1113,6 +1101,222 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Moved to {day}'**
String taskMovedTo(String day);
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Display'**
String get display;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Completed tasks'**
String get displayCompleted;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Sort'**
String get displaySort;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Grouping'**
String get displayGrouping;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Sorting'**
String get displaySorting;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Filter'**
String get displayFilter;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Deadline'**
String get displayDeadline;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Label'**
String get displayLabel;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Reset all'**
String get displayReset;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Done'**
String get displayDone;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'All'**
String get displayAll;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Any'**
String get displayAny;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'None'**
String get groupingNone;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Date'**
String get groupingDate;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Deadline'**
String get groupingDeadline;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Added date'**
String get groupingAdded;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Priority'**
String get groupingPriority;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Label'**
String get groupingLabel;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Manual'**
String get sortingManual;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Name'**
String get sortingName;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Date'**
String get sortingDate;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Deadline'**
String get sortingDeadline;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Added date'**
String get sortingAdded;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Priority'**
String get sortingPriority;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Today'**
String get rangeToday;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'This week'**
String get rangeThisWeek;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Next 7 days'**
String get rangeNext7;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'This month'**
String get rangeThisMonth;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Next 30 days'**
String get rangeNext30;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'No date'**
String get rangeNoDate;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'No deadline'**
String get rangeNoDeadline;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Nothing matches'**
String get viewNothingTitle;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'This view\'s filters leave out every task here. Display changes them.'**
String get viewNothingHint;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Save'**
String get displaySave;
/// A project's Display sheet and what it shows (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'Cancel'**
String get displayCancel;
/// A project's group of tasks without a label (ADR 0033, D17).
///
/// In en, this message translates to:
/// **'No label'**
String get groupNoLabel;
}
class _AppLocalizationsDelegate

View file

@ -55,12 +55,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get colorLabel => 'Farbe';
@override
String get completedShow => 'Erledigte zeigen';
@override
String get completedHide => 'Erledigte ausblenden';
@override
String get dateTitle => 'Datum';
@ -718,4 +712,113 @@ class AppLocalizationsDe extends AppLocalizations {
String taskMovedTo(String day) {
return 'Verschoben auf $day';
}
@override
String get display => 'Anzeige';
@override
String get displayCompleted => 'Erledigte Aufgaben';
@override
String get displaySort => 'Sortieren';
@override
String get displayGrouping => 'Gruppierung';
@override
String get displaySorting => 'Sortierung';
@override
String get displayFilter => 'Filter';
@override
String get displayDeadline => 'Frist';
@override
String get displayLabel => 'Label';
@override
String get displayReset => 'Alles zurücksetzen';
@override
String get displayDone => 'Fertig';
@override
String get displayAll => 'Alle';
@override
String get displayAny => 'Beliebig';
@override
String get groupingNone => 'Keine';
@override
String get groupingDate => 'Datum';
@override
String get groupingDeadline => 'Frist';
@override
String get groupingAdded => 'Hinzugefügt am';
@override
String get groupingPriority => 'Priorität';
@override
String get groupingLabel => 'Label';
@override
String get sortingManual => 'Manuell';
@override
String get sortingName => 'Name';
@override
String get sortingDate => 'Datum';
@override
String get sortingDeadline => 'Frist';
@override
String get sortingAdded => 'Hinzugefügt am';
@override
String get sortingPriority => 'Priorität';
@override
String get rangeToday => 'Heute';
@override
String get rangeThisWeek => 'Diese Woche';
@override
String get rangeNext7 => 'Nächste 7 Tage';
@override
String get rangeThisMonth => 'Dieser Monat';
@override
String get rangeNext30 => 'Nächste 30 Tage';
@override
String get rangeNoDate => 'Kein Datum';
@override
String get rangeNoDeadline => 'Keine Frist';
@override
String get viewNothingTitle => 'Nichts passt';
@override
String get viewNothingHint =>
'Die Filter dieser Ansicht lassen hier jede Aufgabe aus. Unter Anzeige änderst du sie.';
@override
String get displaySave => 'Speichern';
@override
String get displayCancel => 'Abbrechen';
@override
String get groupNoLabel => 'Ohne Label';
}

View file

@ -55,12 +55,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get colorLabel => 'Colour';
@override
String get completedShow => 'Show done tasks';
@override
String get completedHide => 'Hide done tasks';
@override
String get dateTitle => 'Date';
@ -717,6 +711,115 @@ class AppLocalizationsEn extends AppLocalizations {
String taskMovedTo(String day) {
return 'Moved to $day';
}
@override
String get display => 'Display';
@override
String get displayCompleted => 'Completed tasks';
@override
String get displaySort => 'Sort';
@override
String get displayGrouping => 'Grouping';
@override
String get displaySorting => 'Sorting';
@override
String get displayFilter => 'Filter';
@override
String get displayDeadline => 'Deadline';
@override
String get displayLabel => 'Label';
@override
String get displayReset => 'Reset all';
@override
String get displayDone => 'Done';
@override
String get displayAll => 'All';
@override
String get displayAny => 'Any';
@override
String get groupingNone => 'None';
@override
String get groupingDate => 'Date';
@override
String get groupingDeadline => 'Deadline';
@override
String get groupingAdded => 'Added date';
@override
String get groupingPriority => 'Priority';
@override
String get groupingLabel => 'Label';
@override
String get sortingManual => 'Manual';
@override
String get sortingName => 'Name';
@override
String get sortingDate => 'Date';
@override
String get sortingDeadline => 'Deadline';
@override
String get sortingAdded => 'Added date';
@override
String get sortingPriority => 'Priority';
@override
String get rangeToday => 'Today';
@override
String get rangeThisWeek => 'This week';
@override
String get rangeNext7 => 'Next 7 days';
@override
String get rangeThisMonth => 'This month';
@override
String get rangeNext30 => 'Next 30 days';
@override
String get rangeNoDate => 'No date';
@override
String get rangeNoDeadline => 'No deadline';
@override
String get viewNothingTitle => 'Nothing matches';
@override
String get viewNothingHint =>
'This view\'s filters leave out every task here. Display changes them.';
@override
String get displaySave => 'Save';
@override
String get displayCancel => 'Cancel';
@override
String get groupNoLabel => 'No label';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -2,6 +2,8 @@ import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.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/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The schema of a project's payload this app writes and reads.
@ -14,8 +16,49 @@ ByteData encodeProject(ProjectContent content) =>
'name': content.name,
'color': ?content.color?.name,
'position': content.position,
// As at first, nothing: a project's Display is written once set (D17).
if (!content.view.isDefault) 'view': _encodeView(content.view),
});
/// [view] by its values' names, those left as at first left out.
Map<String, Object?> _encodeView(ProjectView view) => {
if (view.layout != ProjectLayout.list) 'layout': view.layout.name,
if (view.completed) 'completed': true,
if (view.grouping != TaskGrouping.none) 'grouping': view.grouping.name,
if (view.sorting != TaskSorting.manual) 'sorting': view.sorting.name,
if (view.date != DayRange.all) 'date': view.date.name,
if (view.deadline != DayRange.all) 'deadline': view.deadline.name,
if (view.priorities.isNotEmpty)
'priorities': [for (final p in view.priorities) p.name],
if (view.labelIds.isNotEmpty) 'labelIds': [...view.labelIds],
};
/// The view [json] holds; a value this version doesn't know, as at first.
ProjectView _decodeView(Object? json) {
if (json is! Map<String, Object?>) {
return const ProjectView();
}
T named<T extends Enum>(List<T> values, String key, T fallback) =>
values.asNameMap()[json[key]] ?? fallback;
final priorities = Priority.values.asNameMap();
return ProjectView(
layout: named(ProjectLayout.values, 'layout', ProjectLayout.list),
completed: json['completed'] == true,
grouping: named(TaskGrouping.values, 'grouping', TaskGrouping.none),
sorting: named(TaskSorting.values, 'sorting', TaskSorting.manual),
date: named(DayRange.values, 'date', DayRange.all),
deadline: named(DayRange.values, 'deadline', DayRange.all),
priorities: {
for (final name in json['priorities'] as List<Object?>? ?? const [])
?priorities[name],
},
labelIds: {
for (final id in json['labelIds'] as List<Object?>? ?? const [])
if (id is String) id,
},
);
}
/// The project [payload] holds, opened; null for one of a newer schema or one
/// it can't read. A colour it doesn't know is none.
ProjectContent? decodeProject(ByteData payload) => decodeJson(
@ -26,5 +69,6 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
name: json['name']! as String,
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
view: _decodeView(json['view']),
),
);

View file

@ -1,15 +1,20 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// What a project is (ADR 0033, D6), as its sealed payload holds it: its
/// [name], its [color] from halkyon_ui's palette (D9; none: the theme's),
/// and its [position] among the person's projects, smaller first. The Inbox
/// its [position] among the person's projects, smaller first, and how it
/// shows its tasks ([view], D17). The Inbox
/// is told by its row's ID, not by anything here (`ProjectRepository`), and
/// shows under the app's own word for it, whatever [name] holds.
final class const ProjectContent({
required final String name,
final PaletteColor? color,
final double position = 0,
/// How the project shows its tasks (D17).
final ProjectView view = const ProjectView(),
}) with Equatable {
/// The Inbox's content, as the first device writes it.
static const inbox = ProjectContent(name: '');
@ -18,12 +23,14 @@ final class const ProjectContent({
String? name,
PaletteColor? Function()? color,
double? position,
ProjectView? view,
}) => ProjectContent(
name: name ?? this.name,
color: color == null ? this.color : color(),
position: position ?? this.position,
view: view ?? this.view,
);
@override
List<Object?> get props => [name, color, position];
List<Object?> get props => [name, color, position, view];
}

View file

@ -0,0 +1,257 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// How a project's tasks are laid out (ADR 0033, D17).
enum ProjectLayout() {
list,
board,
calendar,
}
/// What a project's list groups its tasks by (D17); none: its sections.
enum TaskGrouping() {
none,
date,
deadline,
addedDate,
priority,
label,
}
/// What a project's list sorts its tasks by (D17); manual: as placed.
enum TaskSorting() {
manual,
name,
date,
deadline,
addedDate,
priority,
}
/// The days a filter by date or deadline keeps (D17).
enum DayRange() {
all,
today,
thisWeek,
next7Days,
thisMonth,
next30Days,
/// Only tasks without one.
none;
/// Whether [day] (none: the task has none) falls in this range on
/// [today], the week starting on [weekStart].
bool holds(Day? day, {required Day today, required Day weekStart}) {
if (this == DayRange.all) {
return true;
}
if (day == null) {
return this == DayRange.none;
}
return switch (this) {
DayRange.all => true,
DayRange.none => false,
DayRange.today => day == today,
DayRange.thisWeek => !(day < weekStart) && day < weekStart.plus(7),
DayRange.next7Days => !(day < today) && day < today.plus(7),
DayRange.thisMonth => day.year == today.year && day.month == today.month,
DayRange.next30Days => !(day < today) && day < today.plus(30),
};
}
}
/// A project's Display (ADR 0033, D17, as Todoist's): its [layout], whether
/// it shows its done tasks, how it groups and sorts them, and the tasks it
/// keeps, by date, deadline, priority and label. Kept in the project's
/// sealed row, so every device of the person's shows the project alike.
final class const ProjectView({
final ProjectLayout layout = ProjectLayout.list,
final bool completed = false,
final TaskGrouping grouping = TaskGrouping.none,
final TaskSorting sorting = TaskSorting.manual,
final DayRange date = DayRange.all,
final DayRange deadline = DayRange.all,
/// The priorities kept; none: all.
final Set<Priority> priorities = const {},
/// The labels kept, a task with any of them; none: all.
final Set<String> labelIds = const {},
}) with Equatable {
/// Whether nothing is changed from how a project shows at first.
bool get isDefault => this == const ProjectView();
/// Whether its rows can be dragged into another order: sorted by hand
/// and grouped by sections.
bool get canReorder =>
sorting == TaskSorting.manual && grouping == TaskGrouping.none;
/// Whether it keeps [content] on [today], the week starting on
/// [weekStart]. Done tasks are [completed]'s to keep or not.
bool shows(
TaskContent content, {
required Day today,
required Day weekStart,
}) =>
date.holds(content.date?.day, today: today, weekStart: weekStart) &&
deadline.holds(content.deadline, today: today, weekStart: weekStart) &&
(priorities.isEmpty || priorities.contains(content.priority)) &&
(labelIds.isEmpty || content.labelIds.any(labelIds.contains));
/// Orders two tasks as it sorts them; a tie, or a task without what is
/// sorted by (last), as placed by hand.
int compare(OpenTask a, OpenTask b) {
int nullsLast<T extends Comparable<Object>>(T? x, T? y) => switch ((
x,
y,
)) {
(null, null) => 0,
(null, _) => 1,
(_, null) => -1,
(final x?, final y?) => x.compareTo(y),
};
final first = switch (sorting) {
TaskSorting.manual => 0,
TaskSorting.name => a.content.title.toLowerCase().compareTo(
b.content.title.toLowerCase(),
),
TaskSorting.date => nullsLast(_dateKey(a), _dateKey(b)),
TaskSorting.deadline => nullsLast(
a.content.deadline?.toDate(),
b.content.deadline?.toDate(),
),
TaskSorting.addedDate => nullsLast(addedAt(a.id), addedAt(b.id)),
TaskSorting.priority => a.content.priority.index.compareTo(
b.content.priority.index,
),
};
return first != 0 ? first : byPosition(a, b);
}
/// A task's date and time as one moment, a day without a time first on
/// it.
static DateTime? _dateKey(OpenTask task) => switch (task.content.date) {
final date? => date.day.toDate().add(Duration(minutes: date.time ?? -1)),
null => null,
};
ProjectView copyWith({
ProjectLayout? layout,
bool? completed,
TaskGrouping? grouping,
TaskSorting? sorting,
DayRange? date,
DayRange? deadline,
Set<Priority>? priorities,
Set<String>? labelIds,
}) => ProjectView(
layout: layout ?? this.layout,
completed: completed ?? this.completed,
grouping: grouping ?? this.grouping,
sorting: sorting ?? this.sorting,
date: date ?? this.date,
deadline: deadline ?? this.deadline,
priorities: priorities ?? this.priorities,
labelIds: labelIds ?? this.labelIds,
);
@override
List<Object?> get props => [
layout,
completed,
grouping,
sorting,
date,
deadline,
priorities,
labelIds,
];
}
/// What a group of a project's list gathers (D17).
sealed class const TaskGroupKey() with Equatable;
/// Tasks dated, or due by their deadline, before today.
final class const OverdueGroup() extends TaskGroupKey {
@override
List<Object?> get props => [];
}
/// Tasks of one day (by date, deadline or when added); none: without one.
final class const DayGroup(final Day? day) extends TaskGroupKey {
@override
List<Object?> get props => [day];
}
/// Tasks of one priority.
final class const PriorityGroup(final Priority priority) extends TaskGroupKey {
@override
List<Object?> get props => [priority];
}
/// Tasks with one label; none: without any.
final class const LabelGroup(final String? labelId) extends TaskGroupKey {
@override
List<Object?> get props => [labelId];
}
/// A group of a project's list and its tasks, in their order.
typedef TaskGroup = ({TaskGroupKey key, List<OpenTask> tasks});
/// [tasks], in their order, grouped by [grouping] on [today] (D17): by date
/// or deadline overdue first, then each day, none last; by when added each
/// day; by priority P1 first; by label in [labelOrder], a task under each
/// of its labels, those without last. Groups without tasks are left out.
List<TaskGroup> groupTasks(
List<OpenTask> tasks,
TaskGrouping grouping, {
required Day today,
List<String> labelOrder = const [],
}) {
final groups = <TaskGroupKey, List<OpenTask>>{};
void put(TaskGroupKey key, OpenTask task) => (groups[key] ??= []).add(task);
Day? addedDay(OpenTask task) => switch (addedAt(task.id)) {
final at? => Day.of(at),
null => null,
};
TaskGroupKey byDay(Day? day, {bool overdue = true}) =>
overdue && day != null && day < today
? const OverdueGroup()
: DayGroup(day);
for (final task in tasks) {
switch (grouping) {
case TaskGrouping.none:
put(const DayGroup(null), task);
case TaskGrouping.date:
put(byDay(task.content.date?.day), task);
case TaskGrouping.deadline:
put(byDay(task.content.deadline), task);
case TaskGrouping.addedDate:
put(byDay(addedDay(task), overdue: false), task);
case TaskGrouping.priority:
put(PriorityGroup(task.content.priority), task);
case TaskGrouping.label:
if (task.content.labelIds.isEmpty) {
put(const LabelGroup(null), task);
}
for (final id in task.content.labelIds) {
put(LabelGroup(id), task);
}
}
}
int rank(TaskGroupKey key) => switch (key) {
OverdueGroup() => -1 << 40,
DayGroup(:final day?) => day.toDate().millisecondsSinceEpoch ~/ 86400000,
DayGroup(day: null) => 1 << 40,
PriorityGroup(:final priority) => priority.index,
LabelGroup(:final labelId?) => switch (labelOrder.indexOf(labelId)) {
-1 => 1 << 39,
final index => index,
},
LabelGroup(labelId: null) => 1 << 40,
};
final keys = groups.keys.toList()..sort((a, b) => rank(a).compareTo(rank(b)));
return [for (final key in keys) (key: key, tasks: groups[key]!)];
}

View file

@ -0,0 +1,349 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The button opening a project's Display (ADR 0033, D17), a dot on it
/// while the project shows otherwise than at first.
class const DisplayButton({required final String projectId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final view = context.select<TodoCubit, ProjectView>(
(cubit) =>
cubit.state.projectOf(projectId)?.content?.view ??
const ProjectView(),
);
return IconButton(
tooltip: AppLocalizations.of(context).display,
icon: Badge(
smallSize: 8,
isLabelVisible: !view.isDefault,
child: const Icon(LucideIcons.slidersHorizontal),
),
onPressed: () => showDisplaySheet(context, projectId),
);
}
}
/// A project's Display (D17, as Todoist's): its done tasks shown or not,
/// how it groups and sorts, and what it filters by date, deadline, priority
/// and label; each change written to the project at once, synced to every
/// device, Reset all putting it back as at first.
Future<void> showDisplaySheet(BuildContext context, String projectId) =>
showAdaptiveSheet<void>(
context,
builder: (context) => _DisplaySheet(projectId: projectId),
);
class const _DisplaySheet({required final String projectId})
extends StatelessWidget {
Future<void> _set(BuildContext context, ProjectView view) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final todo = PersonScope.of<TodoPerson>(context);
final project = context.read<TodoCubit>().state.projectOf(projectId);
if (project?.content case final content?) {
try {
await todo.projects.update(projectId, content.copyWith(view: view));
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final view =
state.projectOf(projectId)?.content?.view ?? const ProjectView();
final labels = {
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
};
Widget heading(String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
);
Widget choice(
IconData icon,
String title,
String value,
Future<void> Function() onTap,
) => ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(value),
onTap: onTap,
);
return SheetScaffold(
title: l10n.display,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile(
secondary: const Icon(LucideIcons.circleCheck),
title: Text(l10n.displayCompleted),
value: view.completed,
onChanged: (on) => _set(context, view.copyWith(completed: on)),
),
heading(l10n.displaySort),
choice(
LucideIcons.group,
l10n.displayGrouping,
groupingText(l10n, view.grouping),
() async {
if (await _pickOne(
context,
l10n.displayGrouping,
TaskGrouping.values,
view.grouping,
(grouping) => groupingText(l10n, grouping),
)
case final grouping?) {
if (context.mounted) {
await _set(context, view.copyWith(grouping: grouping));
}
}
},
),
choice(
LucideIcons.arrowUpDown,
l10n.displaySorting,
sortingText(l10n, view.sorting),
() async {
if (await _pickOne(
context,
l10n.displaySorting,
TaskSorting.values,
view.sorting,
(sorting) => sortingText(l10n, sorting),
)
case final sorting?) {
if (context.mounted) {
await _set(context, view.copyWith(sorting: sorting));
}
}
},
),
heading(l10n.displayFilter),
choice(
LucideIcons.calendar,
l10n.dateTitle,
rangeText(l10n, view.date),
() async {
if (await _pickOne(
context,
l10n.dateTitle,
DayRange.values,
view.date,
(range) => rangeText(l10n, range),
)
case final range?) {
if (context.mounted) {
await _set(context, view.copyWith(date: range));
}
}
},
),
choice(
LucideIcons.target,
l10n.displayDeadline,
rangeText(l10n, view.deadline, deadline: true),
() async {
if (await _pickOne(
context,
l10n.displayDeadline,
DayRange.values,
view.deadline,
(range) => rangeText(l10n, range, deadline: true),
)
case final range?) {
if (context.mounted) {
await _set(context, view.copyWith(deadline: range));
}
}
},
),
choice(
LucideIcons.flag,
l10n.priorityTitle,
view.priorities.isEmpty
? l10n.displayAll
: [
for (final priority in Priority.values)
if (view.priorities.contains(priority))
priorityText(l10n, priority),
].join(', '),
() async {
if (await _pickPriorities(context, view.priorities)
case final picked?) {
if (context.mounted) {
await _set(context, view.copyWith(priorities: picked));
}
}
},
),
choice(
LucideIcons.tag,
l10n.displayLabel,
view.labelIds.isEmpty
? l10n.displayAny
: [
for (final id in view.labelIds) ?labels[id],
].join(', '),
() async {
if (await pickLabels(context, [...view.labelIds])
case final picked?) {
if (context.mounted) {
await _set(context, view.copyWith(labelIds: {...picked}));
}
}
},
),
],
),
footer: Row(
children: [
TextButton(
onPressed: view.isDefault
? null
: () => _set(context, const ProjectView()),
child: Text(l10n.displayReset),
),
const Spacer(),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.displayDone),
),
],
),
);
}
}
/// Asks for one of [values], [current] ticked.
Future<T?> _pickOne<T>(
BuildContext context,
String title,
List<T> values,
T current,
String Function(T value) text,
) => showDialog<T>(
context: context,
builder: (context) => SimpleDialog(
title: Text(title),
children: [
for (final value in values)
ListTile(
title: Text(text(value)),
trailing: value == current ? const Icon(LucideIcons.check) : null,
onTap: () => Navigator.of(context).pop(value),
),
],
),
);
/// Asks for the priorities kept, [current] ticked; none ticked keeps all.
Future<Set<Priority>?> _pickPriorities(
BuildContext context,
Set<Priority> current,
) {
final l10n = AppLocalizations.of(context);
var picked = {...current};
return showDialog<Set<Priority>>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(l10n.priorityTitle),
contentPadding: const EdgeInsets.symmetric(vertical: 8),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final priority in Priority.values)
CheckboxListTile(
secondary: Icon(
LucideIcons.flag,
color: priorityColor(context, priority),
),
title: Text(priorityText(l10n, priority)),
value: picked.contains(priority),
onChanged: (on) => setState(() {
picked = {...picked};
if (on ?? false) {
picked.add(priority);
} else {
picked.remove(priority);
}
}),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.displayCancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(picked),
child: Text(l10n.displaySave),
),
],
),
),
);
}
/// [grouping]'s name.
String groupingText(AppLocalizations l10n, TaskGrouping grouping) =>
switch (grouping) {
TaskGrouping.none => l10n.groupingNone,
TaskGrouping.date => l10n.groupingDate,
TaskGrouping.deadline => l10n.groupingDeadline,
TaskGrouping.addedDate => l10n.groupingAdded,
TaskGrouping.priority => l10n.groupingPriority,
TaskGrouping.label => l10n.groupingLabel,
};
/// [sorting]'s name.
String sortingText(AppLocalizations l10n, TaskSorting sorting) =>
switch (sorting) {
TaskSorting.manual => l10n.sortingManual,
TaskSorting.name => l10n.sortingName,
TaskSorting.date => l10n.sortingDate,
TaskSorting.deadline => l10n.sortingDeadline,
TaskSorting.addedDate => l10n.sortingAdded,
TaskSorting.priority => l10n.sortingPriority,
};
/// [range]'s name, for a filter by date or, where [deadline], by deadline.
String rangeText(
AppLocalizations l10n,
DayRange range, {
bool deadline = false,
}) => switch (range) {
DayRange.all => l10n.displayAll,
DayRange.today => l10n.rangeToday,
DayRange.thisWeek => l10n.rangeThisWeek,
DayRange.next7Days => l10n.rangeNext7,
DayRange.thisMonth => l10n.rangeThisMonth,
DayRange.next30Days => l10n.rangeNext30,
DayRange.none => deadline ? l10n.rangeNoDeadline : l10n.rangeNoDate,
};

View file

@ -4,6 +4,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
@ -14,58 +15,49 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The screen of the project [projectId] (the Inbox's too): its tasks, a
/// button to add one there, and a menu to show the done ones, add a
/// section and, but for the Inbox, edit or delete the project.
/// button to add one there, its Display (D17: its done tasks, grouping,
/// sorting, filters), and a menu to add a section and, but for the Inbox,
/// edit or delete the project.
class const ProjectScreenBody({
required final String title,
required final String? projectId,
final List<PopupMenuEntry<VoidCallback>> Function(BuildContext context)? menu,
super.key,
}) extends StatefulWidget {
@override
State<ProjectScreenBody> createState() => _ProjectScreenBodyState();
}
class _ProjectScreenBodyState() extends State<ProjectScreenBody> {
var _completed = false;
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final projectId = widget.projectId;
return TabScreen(
title: widget.title,
title: title,
actions: [
if (projectId != null)
if (projectId case final projectId?) ...[
DisplayButton(projectId: projectId),
PopupMenuButton<VoidCallback>(
tooltip: l10n.projectMenu,
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () => setState(() => _completed = !_completed),
child: MenuEntry(
_completed ? LucideIcons.eyeOff : LucideIcons.circleCheck,
_completed ? l10n.completedHide : l10n.completedShow,
),
),
PopupMenuItem(
value: () => addSection(context, projectId),
child: MenuEntry(LucideIcons.listPlus, l10n.sectionAdd),
),
selectTasksEntry(context),
...?widget.menu?.call(context),
...?menu?.call(context),
],
),
],
],
floatingActionButton: projectId == null
? null
: AddTaskButton(onPressed: () => showQuickAddIn(context, projectId)),
floatingActionButton: switch (projectId) {
final id? => AddTaskButton(
onPressed: () => showQuickAddIn(context, id),
),
null => null,
},
body: Column(
children: [
const KeysResetBanner(),
Expanded(
child: switch (projectId) {
final id? => ProjectTasks(projectId: id, completed: _completed),
final id? => ProjectTasks(projectId: id),
// No Inbox yet: made by the first device holding the
// account's keys, or still on its way here.
null

View file

@ -1,17 +1,23 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/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_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';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -19,18 +25,20 @@ import 'package:material_ui/material_ui.dart';
/// A project's tasks as a list (ADR 0033, D8): those in no section first,
/// then each section under its name, count and description, with a menu to
/// add a task there, rename it or delete it; done tasks too where
/// [completed]. Sub-tasks show in their task's sheet, counted on its row.
/// the project's Display asks. Sub-tasks show in their task's sheet, counted on its row.
/// A section's heading stays atop the list while its tasks scroll beneath
/// it, until the next one pushes it away (the developer's, 1 Nov, D14);
/// the rows are built as they scroll into view. A row held is dragged into
/// another place, or a little to the right to become the sub-task of the
/// row it is dropped below, and the add button dropped between two rows
/// adds a task there (D16).
class const ProjectTasks({
required final String projectId,
final bool completed = false,
super.key,
}) extends StatelessWidget {
///
/// What it shows, in what order and grouped how, is the project's Display
/// ([ProjectView], D17): grouped otherwise than by sections, its groups
/// stand in their place; its rows are dragged only while it is sorted by
/// hand and grouped by sections.
class const ProjectTasks({required final String projectId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -38,7 +46,24 @@ class const ProjectTasks({
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final loose = tasksIn(state.tasks, projectId, completed: completed);
final view =
state.projectOf(projectId)?.content?.view ?? const ProjectView();
final today = Day.of(clock.now());
final weekStart = WeekStrip.weekOf(
today,
MaterialLocalizations.of(context).firstDayOfWeekIndex,
);
// The project's tasks of [sectionId] its Display keeps, in its order.
List<OpenTask> kept({String? sectionId}) => [
for (final task in tasksIn(
state.tasks,
projectId,
sectionId: sectionId,
completed: view.completed,
))
if (view.shows(task.content, today: today, weekStart: weekStart)) task,
]..sort(view.compare);
final loose = kept();
final sections = state.sectionsOf(projectId);
if (loose.isEmpty &&
sections.isEmpty &&
@ -56,16 +81,19 @@ class const ProjectTasks({
final groups = [
(sectionId: null, tasks: loose),
for (final section in sections)
(
sectionId: section.id,
tasks: tasksIn(
state.tasks,
projectId,
sectionId: section.id,
completed: completed,
),
),
(sectionId: section.id, tasks: kept(sectionId: section.id)),
];
if (view.grouping != TaskGrouping.none) {
return _grouped(
context,
state,
view,
[
for (final group in groups) ...group.tasks,
]..sort(view.compare),
today,
);
}
final last = groups.last;
return NewTaskDropArea(
// Below every row: last in the last group.
@ -85,11 +113,24 @@ class const ProjectTasks({
SliverList.list(
children: [
for (final (index, task) in loose.indexed)
_row(state, groups.first, index, task),
_row(
state,
groups.first,
index,
task,
reorder: view.canReorder,
),
],
),
for (final (index, section) in sections.indexed)
_section(context, state, section, groups[index], groups[index + 1]),
_section(
context,
state,
section,
groups[index],
groups[index + 1],
reorder: view.canReorder,
),
SliverPadding(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
@ -100,8 +141,93 @@ class const ProjectTasks({
);
}
/// [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.
Widget _grouped(
BuildContext context,
TodoState state,
ProjectView view,
List<OpenTask> tasks,
Day today,
) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (tasks.isEmpty) {
return Center(
child: EmptyListMessage(
icon: LucideIcons.listFilter,
title: l10n.viewNothingTitle,
hint: l10n.viewNothingHint,
),
);
}
final labels = {
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,
};
final groups = groupTasks(
tasks,
view.grouping,
today: today,
labelOrder: [for (final label in state.labels) label.id],
);
return CustomScrollView(
slivers: [
for (final (index, group) in groups.indexed)
SliverMainAxisGroup(
slivers: [
if (index > 0)
const SliverToBoxAdapter(child: Divider(height: 24)),
PinnedHeaderSliver(
child: ColoredBox(
color: theme.colorScheme.surface,
child: ListTile(
title: Text(
'${title(group.key)} ${group.tasks.length}',
style: theme.textTheme.titleMedium,
),
),
),
),
SliverList.builder(
itemCount: group.tasks.length,
itemBuilder: (context, index) =>
TaskTile(task: group.tasks[index]),
),
],
),
SliverPadding(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
),
],
);
}
/// [task], the [index]th of [group], dragged and dropped on (D16).
Widget _row(TodoState state, _Group group, int index, OpenTask task) {
Widget _row(
TodoState state,
_Group group,
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(
@ -137,8 +263,9 @@ class const ProjectTasks({
TodoState state,
StoredSection section,
_Group previous,
_Group group,
) {
_Group group, {
required bool reorder,
}) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final content = section.content!;
@ -228,7 +355,7 @@ class const ProjectTasks({
SliverList.builder(
itemCount: tasks.length,
itemBuilder: (context, index) =>
_row(state, group, index, tasks[index]),
_row(state, group, index, tasks[index], reorder: reorder),
),
],
);

View file

@ -11,6 +11,7 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
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/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';
@ -358,6 +359,144 @@ void main() {
expect(taskRows.byId.values.single.keyId, isNotNull);
});
group("a project's Display (D17)", () {
Finder row(String title) => find.widgetWithText(ListTile, title);
Future<void> display(WidgetTester tester) async {
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
}
Future<void> pick(
WidgetTester tester,
String setting,
String value,
) async {
await tester.tap(find.widgetWithText(ListTile, setting));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, value).last);
await tester.pumpAndSettle();
}
Future<void> done(WidgetTester tester) async {
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
await tester.pumpAndSettle();
}
Future<void> inboxOf(WidgetTester tester) async {
await start(tester, usedBefore: true);
for (final (index, (title, priority)) in [
('cherry', Priority.none),
('Apple', Priority.p1),
('banana', Priority.p2),
].indexed) {
await seed(
tester,
TaskContent(
title: title,
projectId: inbox(),
position: index.toDouble(),
priority: priority,
),
);
}
await goTab(tester, 'Inbox');
}
List<String> order(WidgetTester tester) => ['cherry', 'Apple', 'banana']
..sort(
(a, b) => tester
.getCenter(row(a))
.dy
.compareTo(tester.getCenter(row(b)).dy),
);
testWidgets('sorting by name orders the list, kept in the project for '
'every device; a dot marks the button; Reset all puts it back', (
tester,
) async {
await inboxOf(tester);
expect(order(tester), ['cherry', 'Apple', 'banana']);
expect(
tester.widget<Badge>(find.byType(Badge).first).isLabelVisible,
isFalse,
);
await display(tester);
await pick(tester, 'Sorting', 'Name');
await done(tester);
expect(order(tester), ['Apple', 'banana', 'cherry']);
final projects = await tester.runAsync(
() => ProjectRepository(projectRows, keys).all(),
);
expect(
projects!
.singleWhere((project) => project.id == inbox())
.content
?.view
.sorting,
TaskSorting.name,
);
expect(
tester.widget<Badge>(find.byType(Badge).first).isLabelVisible,
isTrue,
);
await display(tester);
await tester.tap(find.text('Reset all'));
await tester.pumpAndSettle();
await done(tester);
expect(order(tester), ['cherry', 'Apple', 'banana']);
});
testWidgets('grouped by priority, each priority under its heading', (
tester,
) async {
await inboxOf(tester);
await display(tester);
await pick(tester, 'Grouping', 'Priority');
await done(tester);
expect(find.text('Priority 1 1'), findsOneWidget);
expect(find.text('Priority 2 1'), findsOneWidget);
expect(find.text('No priority 1'), findsOneWidget);
});
testWidgets('a filter by priority leaves out the others; none left, it '
'says so', (tester) async {
await inboxOf(tester);
await display(tester);
await tester.tap(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.widgetWithText(ListTile, 'Priority 1'), findsOneWidget);
await done(tester);
expect(row('Apple'), findsOneWidget);
expect(row('banana'), findsNothing);
expect(row('cherry'), findsNothing);
});
testWidgets('rows are dragged only while sorted by hand', (tester) async {
await inboxOf(tester);
await display(tester);
await pick(tester, 'Sorting', 'Priority');
await done(tester);
expect(find.byType(DraggableTask), findsNothing);
await display(tester);
await pick(tester, 'Sorting', 'Manual');
await done(tester);
expect(find.byType(DraggableTask), findsNWidgets(3));
});
});
group('quick add reads what is typed (D15)', () {
Future<void> open(WidgetTester tester, String text) async {
await tester.tap(find.byTooltip('Add task'));
@ -573,9 +712,11 @@ void main() {
await tester.tap(find.byTooltip('Mark as done'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('More'));
await tester.tap(find.byTooltip('Display'));
await tester.pumpAndSettle();
await tester.tap(find.text('Show done tasks'));
await tester.tap(find.text('Completed tasks'));
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
final title = tester.widget<Text>(find.text('Water plants'));
expect(title.style?.decoration, TextDecoration.lineThrough);

View file

@ -4,6 +4,8 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.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/task/model/task.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// A project's payload (ADR 0033, D6): UTF-8 JSON with its schema's version,
@ -26,6 +28,45 @@ void main() {
);
});
test("a project's Display reads back as it was set, by names; left "
'as at first, nothing of it is written (D17)', () {
const view = ProjectView(
completed: true,
grouping: TaskGrouping.label,
sorting: TaskSorting.priority,
date: DayRange.next7Days,
deadline: DayRange.none,
priorities: {Priority.p1, Priority.none},
labelIds: {'l1'},
);
const home = ProjectContent(name: 'Home', view: view);
expect(decodeProject(encodeProject(home)), home);
final written = jsonDecode(
utf8.decode(Uint8List.sublistView(encodeProject(home))),
) as Map<String, Object?>;
expect(written['view'], {
'completed': true,
'grouping': 'label',
'sorting': 'priority',
'date': 'next7Days',
'deadline': 'none',
'priorities': ['p1', 'none'],
'labelIds': ['l1'],
});
});
test('a Display value this version does not know reads as at first', () {
final read = decodeProject(
json({
'schemaVersion': 1,
'name': 'Home',
'view': {'layout': 'timeline', 'sorting': 'mood', 'completed': true},
}),
);
expect(read?.view, const ProjectView(completed: true));
});
test('the payload names its schema and the colour by its name', () {
final written = jsonDecode(
utf8.decode(

View file

@ -0,0 +1,232 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// A project's Display (ADR 0033, D17): what it filters, how it sorts and
/// groups.
void main() {
// Sunday, 1 Nov 2026; weeks from Monday.
const today = Day(2026, 11, 1);
const weekStart = Day(2026, 10, 26);
OpenTask task(
String id, {
double position = 0,
Day? day,
int? time,
Day? deadline,
Priority priority = Priority.none,
List<String> labels = const [],
String? section,
}) => (
id: id,
content: TaskContent(
title: id,
projectId: 'p',
sectionId: section,
position: position,
date: day == null ? null : TaskDate(day, time: time),
deadline: deadline,
priority: priority,
labelIds: labels,
),
);
List<String> ids(List<OpenTask> tasks) => [for (final t in tasks) t.id];
List<String> filtered(List<OpenTask> tasks, ProjectView view) => ids([
for (final t in tasks)
if (view.shows(t.content, today: today, weekStart: weekStart)) t,
]);
test('the default view filters nothing, sorts by hand, groups by '
'sections, done tasks hidden', () {
const view = ProjectView();
expect(view.isDefault, isTrue);
expect(view.layout, ProjectLayout.list);
expect(view.completed, isFalse);
expect(view.grouping, TaskGrouping.none);
expect(view.sorting, TaskSorting.manual);
expect(view.canReorder, isTrue);
expect(view.copyWith(sorting: TaskSorting.name).canReorder, isFalse);
expect(view.copyWith(grouping: TaskGrouping.date).canReorder, isFalse);
expect(view.copyWith(completed: true).isDefault, isFalse);
});
group('filters by date', () {
final tasks = [
task('none'),
task('past', day: today.plus(-1)),
task('today', day: today),
task('in 3', day: today.plus(3)),
task('in 10', day: today.plus(10)),
task('in 29', day: today.plus(29)),
task('in 31', day: today.plus(31)),
];
for (final (range, expected) in [
(
DayRange.all,
['none', 'past', 'today', 'in 3', 'in 10', 'in 29', 'in 31'],
),
(DayRange.today, ['today']),
(DayRange.thisWeek, ['past', 'today']),
(DayRange.next7Days, ['today', 'in 3']),
(DayRange.thisMonth, ['today', 'in 3', 'in 10', 'in 29']),
(DayRange.next30Days, ['today', 'in 3', 'in 10', 'in 29']),
(DayRange.none, ['none']),
]) {
test(range.name, () {
expect(filtered(tasks, ProjectView(date: range)), expected);
});
}
});
test('filters by deadline, priority and label, all at once', () {
final tasks = [
task('a', deadline: today.plus(2), priority: Priority.p1, labels: ['x']),
task('b', deadline: today.plus(2), priority: Priority.p2, labels: ['x']),
task('c', priority: Priority.p1, labels: ['x']),
task('d', deadline: today, priority: Priority.p1, labels: ['y']),
];
expect(
filtered(tasks, const ProjectView(deadline: DayRange.next7Days)),
['a', 'b', 'd'],
);
expect(filtered(tasks, const ProjectView(deadline: DayRange.none)), ['c']);
expect(
filtered(tasks, const ProjectView(priorities: {Priority.p1})),
['a', 'c', 'd'],
);
expect(
filtered(tasks, const ProjectView(priorities: {Priority.none})),
isEmpty,
);
expect(filtered(tasks, const ProjectView(labelIds: {'y'})), ['d']);
expect(
filtered(
tasks,
const ProjectView(
deadline: DayRange.next7Days,
priorities: {Priority.p1},
labelIds: {'x'},
),
),
['a'],
);
});
test(
'sorts by name, date, deadline, priority; ties and the rest by hand',
() {
final tasks = [
task('cherry', position: 1, day: today, time: 9 * 60),
task('Apple', position: 2, deadline: today.plus(1)),
task('banana', position: 3, day: today, priority: Priority.p1),
task('date', day: today.plus(1), priority: Priority.p2),
];
List<String> sorted(TaskSorting sorting) =>
ids([...tasks]..sort(ProjectView(sorting: sorting).compare));
expect(sorted(TaskSorting.manual), ['date', 'cherry', 'Apple', 'banana']);
expect(sorted(TaskSorting.name), ['Apple', 'banana', 'cherry', 'date']);
expect(
sorted(TaskSorting.date),
['banana', 'cherry', 'date', 'Apple'],
reason: 'a day without a time first on it, none last',
);
expect(sorted(TaskSorting.deadline), [
'Apple',
'date',
'cherry',
'banana',
]);
expect(sorted(TaskSorting.priority), [
'banana',
'date',
'cherry',
'Apple',
]);
},
);
group('groups', () {
test('by date: overdue, each day, no date last', () {
final groups = groupTasks(
[
task('later', day: today.plus(3)),
task('none'),
task('past', day: today.plus(-2)),
task('now', day: today),
],
TaskGrouping.date,
today: today,
);
expect(
[for (final g in groups) g.key],
[
const OverdueGroup(),
const DayGroup(today),
DayGroup(today.plus(3)),
const DayGroup(null),
],
);
expect(ids(groups.first.tasks), ['past']);
});
test('by priority, P1 first; by label, a task under each of its '
'labels, those without last', () {
final tasks = [
task('low'),
task('high', priority: Priority.p1, labels: ['b', 'a']),
task('mid', priority: Priority.p3, labels: ['a']),
];
expect(
[
for (final g in groupTasks(
tasks,
TaskGrouping.priority,
today: today,
))
(g.key, ids(g.tasks).join(',')),
],
[
(const PriorityGroup(Priority.p1), 'high'),
(const PriorityGroup(Priority.p3), 'mid'),
(const PriorityGroup(Priority.none), 'low'),
],
);
expect(
[
for (final g in groupTasks(
tasks,
TaskGrouping.label,
today: today,
labelOrder: ['a', 'b'],
))
(g.key, ids(g.tasks).join(',')),
],
[
(const LabelGroup('a'), 'high,mid'),
(const LabelGroup('b'), 'high'),
(const LabelGroup(null), 'low'),
],
);
});
test('by deadline, as by date', () {
final groups = groupTasks(
[task('a', deadline: today), task('b')],
TaskGrouping.deadline,
today: today,
);
expect(
[for (final g in groups) g.key],
[
const DayGroup(today),
const DayGroup(null),
],
);
});
});
}

View file

@ -242,6 +242,27 @@ alternative was put to them, the choice is theirs.
their time, duration and recurrence kept, *Moved to …* offering to undo it; their
order within a day stays their times' and priorities', and Today's rows aren't
dragged.
- **D17 Each project's Display, synced** (the developer's, 1 Nov, as Todoist's; D8's
later step): a project's screen, the Inbox's too, has a Display button beside its ⋮, a
dot on it while anything differs from how a project shows at first. Its sheet sets
completed tasks (moved out of the ⋮), grouping (none, which keeps the sections; date,
deadline, added date, priority, label), sorting (manual, name, date, deadline, added
date, priority; a tie, or a task without what is sorted by, last and by hand), and
filters by date and deadline (all, today, this week from the language's first
weekday, next 7 days, this month, next 30 days, none) and by priority and label (any
ticked). Each change is written at once; Reset all puts the project back. Grouped,
the groups stand in the sections' place under headings that stay atop the list
(overdue first, then each day, none last; P1 first; a task under each of its labels);
rows are dragged (D16) only while sorted by hand and grouped by sections. The choices
are kept **in the project's sealed row**, so every device of the person's shows the
project alike (`ProjectView`, the payload's `view`, written only once set; values a
version doesn't know read as at first). The schema stays 1: an older version reads
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
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
phones, side by side on wide windows, rows dragged between columns), Calendar for
projects (the week strip and agenda), Upcoming's 3-day grid.
## Steps