feat(todo): Today, Upcoming, Inbox, projects, quick add and the task sheet (ADR 0033, D8, D9)

The tabs hold the person's tasks: Today what is overdue (Reschedule
moving it all to a day, times kept) and what is due today; Upcoming the
overdue ones and the next four weeks day by day; Inbox and each
project's screen from Browse the tasks above and in their sections, done
ones on asking; Browse the projects with their open counts and the
labels. A task's row shows its check in its priority's colour, its date
in the colour of how near it is, its deadline, its sub-tasks done and
its labels; quick add takes its title, date, priority, project, labels
and description and stays for the next; its sheet edits all of it, adds
sub-tasks and deletes it with them after asking. Colours are the
palette's, cerise for overdue and P1 against the strawberry wash.

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 10:25:34 +01:00
commit 030c62ddde
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
30 changed files with 3647 additions and 121 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`). 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 Today (where it opens), Upcoming, Inbox and Browse, each saying tasks come later until they do (`lib/src/tabs/`), 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 Today (where it opens), Upcoming, Inbox and Browse, (`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) and its sheet (`openTaskSheet`), 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`); `.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

@ -15,11 +15,84 @@
"shellTabUpcoming": "Demnächst",
"shellTabInbox": "Eingang",
"shellTabBrowse": "Übersicht",
"todayLaterTitle": "Hier stehen die Aufgaben von heute",
"upcomingLaterTitle": "Hier stehen die nächsten Tage",
"inboxLaterTitle": "Hier stehen Aufgaben ohne Projekt",
"browseLaterTitle": "Hier stehen deine Projekte und Labels",
"tasksLaterHint": "Aufgaben kommen mit einer späteren Version von Halkyon Todo.",
"settingsTitle": "Einstellungen",
"settingsVersion": "Version {version} (Build {build})"
"settingsVersion": "Version {version} (Build {build})",
"clear": "Entfernen",
"colorLabel": "Farbe",
"completedShow": "Erledigte zeigen",
"completedHide": "Erledigte ausblenden",
"dateTitle": "Datum",
"dateNextWeek": "Nächste Woche",
"dateWeekend": "Dieses Wochenende",
"datePick": "Tag wählen",
"dateTime": "Uhrzeit",
"dateNone": "Kein Datum",
"dateNoneYet": "Kein Datum",
"dayToday": "Heute",
"dayTomorrow": "Morgen",
"dayYesterday": "Gestern",
"dayHeadingNamed": "{date} · {name}",
"deadlineOn": "Frist {day}",
"deadlineNone": "Keine Frist",
"keysMissing": "Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.",
"labelsTitle": "Labels",
"labelsNone": "Noch keine Labels.",
"labelsNoneSet": "Keine Labels",
"labelsCount": "{count, plural, =1{1 Label} other{{count} Labels}}",
"labelAdd": "Neues Label",
"labelEdit": "Label bearbeiten",
"labelDelete": "Label löschen",
"labelDeleteTitle": "Label löschen?",
"labelDeleteMessage": "„{name}“ wird von jeder Aufgabe entfernt, die es hat.",
"nameLabel": "Name",
"save": "Speichern",
"overdue": "Überfällig",
"reschedule": "Verschieben",
"priorityTitle": "Priorität",
"priority1": "Priorität 1",
"priority2": "Priorität 2",
"priority3": "Priorität 3",
"priority4": "Keine Priorität",
"projectsTitle": "Projekte",
"projectsNone": "Noch keine eigenen Projekte.",
"projectAdd": "Neues Projekt",
"projectEdit": "Projekt bearbeiten",
"projectDelete": "Projekt löschen",
"projectDeleteTitle": "Projekt löschen?",
"projectDeleteMessage": "„{name}“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.",
"projectEmptyTitle": "Hier ist nichts zu tun",
"projectEmptyHint": "Füge mit dem Plus eine Aufgabe hinzu.",
"projectGone": "Dieses Projekt wurde gelöscht.",
"projectMenu": "Mehr",
"projectPickTitle": "Projekt",
"sectionAdd": "Neuer Abschnitt",
"sectionEdit": "Abschnitt umbenennen",
"sectionDelete": "Abschnitt löschen",
"sectionDeleteTitle": "Abschnitt löschen?",
"sectionDeleteMessage": "„{name}“ wird mit seinen Aufgaben gelöscht.",
"sectionMenu": "Abschnitt",
"subTasks": "Unteraufgaben",
"subTasksDone": "Unteraufgaben {done}/{count}",
"subTaskAdd": "Unteraufgabe hinzufügen",
"taskTitle": "Aufgabe",
"taskAddTitle": "Aufgabe hinzufügen",
"taskAdd": "Hinzufügen",
"taskAddOn": "Aufgabe für {day} hinzufügen",
"taskTitleHint": "Was ist zu tun?",
"taskDescription": "Beschreibung",
"taskDescriptionHint": "Beschreibung",
"taskComplete": "Als erledigt markieren",
"taskReopen": "Als unerledigt markieren",
"taskCompleted": "Aufgabe erledigt",
"taskDelete": "Aufgabe löschen",
"taskDeleteTitle": "Aufgabe löschen?",
"taskDeleteMessage": "„{title}“ wird auf all deinen Geräten gelöscht.",
"taskDeleteWithSubTasks": "„{title}“ wird mit {count, plural, =1{seiner Unteraufgabe} other{seinen {count} Unteraufgaben}} auf all deinen Geräten gelöscht.",
"taskGone": "Diese Aufgabe wurde gelöscht.",
"todayEmptyTitle": "Heute ist nichts fällig",
"todayEmptyHint": "Aufgaben mit dem heutigen Datum und überfällige erscheinen hier.",
"lockedRows": "{count, plural, =1{1 Eintrag lässt sich auf diesem Gerät nicht öffnen.} other{{count} Einträge lassen sich auf diesem Gerät nicht öffnen.}}",
"inboxMissingTitle": "Noch kein Eingang",
"inboxMissingHint": "Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat."
}

View file

@ -17,12 +17,96 @@
"shellTabUpcoming": "Upcoming",
"shellTabInbox": "Inbox",
"shellTabBrowse": "Browse",
"todayLaterTitle": "Today's tasks come here",
"upcomingLaterTitle": "The days ahead come here",
"inboxLaterTitle": "Tasks without a project come here",
"browseLaterTitle": "Your projects and labels come here",
"tasksLaterHint": "Tasks come with a later version of Halkyon Todo.",
"settingsTitle": "Settings",
"settingsVersion": "Version {version} (build {build})",
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}}
"@settingsVersion": {"placeholders": {"version": {"type": "String"}, "build": {"type": "String"}}},
"clear": "Clear",
"colorLabel": "Colour",
"completedShow": "Show done tasks",
"completedHide": "Hide done tasks",
"dateTitle": "Date",
"dateNextWeek": "Next week",
"dateWeekend": "This weekend",
"datePick": "Pick a day",
"dateTime": "Time",
"dateNone": "No date",
"dateNoneYet": "No date",
"dayToday": "Today",
"dayTomorrow": "Tomorrow",
"dayYesterday": "Yesterday",
"dayHeadingNamed": "{date} · {name}",
"@dayHeadingNamed": {"placeholders": {"date": {"type": "String"}, "name": {"type": "String"}}},
"deadlineOn": "Deadline {day}",
"@deadlineOn": {"placeholders": {"day": {"type": "String"}}},
"deadlineNone": "No deadline",
"keysMissing": "This device can't save: it doesn't hold your account's keys. Sign in with your password or recovery phrase to add them.",
"labelsTitle": "Labels",
"labelsNone": "No labels yet.",
"labelsNoneSet": "No labels",
"labelsCount": "{count, plural, =1{1 label} other{{count} labels}}",
"@labelsCount": {"placeholders": {"count": {"type": "int"}}},
"labelAdd": "New label",
"labelEdit": "Edit label",
"labelDelete": "Delete label",
"labelDeleteTitle": "Delete the label?",
"labelDeleteMessage": "\"{name}\" is taken off every task that has it.",
"@labelDeleteMessage": {"placeholders": {"name": {"type": "String"}}},
"nameLabel": "Name",
"save": "Save",
"overdue": "Overdue",
"reschedule": "Reschedule",
"priorityTitle": "Priority",
"priority1": "Priority 1",
"priority2": "Priority 2",
"priority3": "Priority 3",
"priority4": "No priority",
"projectsTitle": "Projects",
"projectsNone": "No projects of your own yet.",
"projectAdd": "New project",
"projectEdit": "Edit project",
"projectDelete": "Delete project",
"projectDeleteTitle": "Delete the project?",
"projectDeleteMessage": "\"{name}\" is deleted with its sections and tasks, on all your devices.",
"@projectDeleteMessage": {"placeholders": {"name": {"type": "String"}}},
"projectEmptyTitle": "Nothing to do here",
"projectEmptyHint": "Add a task with the + button.",
"projectGone": "This project was deleted.",
"projectMenu": "More",
"projectPickTitle": "Project",
"sectionAdd": "New section",
"sectionEdit": "Rename section",
"sectionDelete": "Delete section",
"sectionDeleteTitle": "Delete the section?",
"sectionDeleteMessage": "\"{name}\" is deleted with its tasks.",
"@sectionDeleteMessage": {"placeholders": {"name": {"type": "String"}}},
"sectionMenu": "Section",
"subTasks": "Sub-tasks",
"subTasksDone": "Sub-tasks {done}/{count}",
"@subTasksDone": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
"subTaskAdd": "Add sub-task",
"taskTitle": "Task",
"taskAddTitle": "Add task",
"taskAdd": "Add",
"taskAddOn": "Add a task on {day}",
"@taskAddOn": {"placeholders": {"day": {"type": "String"}}},
"taskTitleHint": "What's to be done?",
"taskDescription": "Description",
"taskDescriptionHint": "Description",
"taskComplete": "Mark as done",
"taskReopen": "Mark as not done",
"taskCompleted": "Task done",
"taskDelete": "Delete task",
"taskDeleteTitle": "Delete the task?",
"taskDeleteMessage": "\"{title}\" is deleted on all your devices.",
"@taskDeleteMessage": {"placeholders": {"title": {"type": "String"}}},
"taskDeleteWithSubTasks": "\"{title}\" is deleted with its {count, plural, =1{sub-task} other{{count} sub-tasks}}, on all your devices.",
"@taskDeleteWithSubTasks": {"placeholders": {"title": {"type": "String"}, "count": {"type": "int"}}},
"taskGone": "This task was deleted.",
"todayEmptyTitle": "Nothing due today",
"todayEmptyHint": "Tasks dated today, and those overdue, show here.",
"lockedRows": "{count, plural, =1{1 item doesn't open on this device.} other{{count} items don't open on this device.}}",
"@lockedRows": {"placeholders": {"count": {"type": "int"}}},
"inboxMissingTitle": "No Inbox yet",
"inboxMissingHint": "Your Inbox comes with the next sync, or once this device holds your account's keys."
}

View file

@ -160,36 +160,6 @@ abstract class AppLocalizations {
/// **'Browse'**
String get shellTabBrowse;
/// No description provided for @todayLaterTitle.
///
/// In en, this message translates to:
/// **'Today\'s tasks come here'**
String get todayLaterTitle;
/// No description provided for @upcomingLaterTitle.
///
/// In en, this message translates to:
/// **'The days ahead come here'**
String get upcomingLaterTitle;
/// No description provided for @inboxLaterTitle.
///
/// In en, this message translates to:
/// **'Tasks without a project come here'**
String get inboxLaterTitle;
/// No description provided for @browseLaterTitle.
///
/// In en, this message translates to:
/// **'Your projects and labels come here'**
String get browseLaterTitle;
/// No description provided for @tasksLaterHint.
///
/// In en, this message translates to:
/// **'Tasks come with a later version of Halkyon Todo.'**
String get tasksLaterHint;
/// No description provided for @settingsTitle.
///
/// In en, this message translates to:
@ -201,6 +171,468 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Version {version} (build {build})'**
String settingsVersion(String version, String build);
/// No description provided for @clear.
///
/// In en, this message translates to:
/// **'Clear'**
String get clear;
/// No description provided for @colorLabel.
///
/// In en, this message translates to:
/// **'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:
/// **'Date'**
String get dateTitle;
/// No description provided for @dateNextWeek.
///
/// In en, this message translates to:
/// **'Next week'**
String get dateNextWeek;
/// No description provided for @dateWeekend.
///
/// In en, this message translates to:
/// **'This weekend'**
String get dateWeekend;
/// No description provided for @datePick.
///
/// In en, this message translates to:
/// **'Pick a day'**
String get datePick;
/// No description provided for @dateTime.
///
/// In en, this message translates to:
/// **'Time'**
String get dateTime;
/// No description provided for @dateNone.
///
/// In en, this message translates to:
/// **'No date'**
String get dateNone;
/// No description provided for @dateNoneYet.
///
/// In en, this message translates to:
/// **'No date'**
String get dateNoneYet;
/// No description provided for @dayToday.
///
/// In en, this message translates to:
/// **'Today'**
String get dayToday;
/// No description provided for @dayTomorrow.
///
/// In en, this message translates to:
/// **'Tomorrow'**
String get dayTomorrow;
/// No description provided for @dayYesterday.
///
/// In en, this message translates to:
/// **'Yesterday'**
String get dayYesterday;
/// No description provided for @dayHeadingNamed.
///
/// In en, this message translates to:
/// **'{date} · {name}'**
String dayHeadingNamed(String date, String name);
/// No description provided for @deadlineOn.
///
/// In en, this message translates to:
/// **'Deadline {day}'**
String deadlineOn(String day);
/// No description provided for @deadlineNone.
///
/// In en, this message translates to:
/// **'No deadline'**
String get deadlineNone;
/// No description provided for @keysMissing.
///
/// In en, this message translates to:
/// **'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.'**
String get keysMissing;
/// No description provided for @labelsTitle.
///
/// In en, this message translates to:
/// **'Labels'**
String get labelsTitle;
/// No description provided for @labelsNone.
///
/// In en, this message translates to:
/// **'No labels yet.'**
String get labelsNone;
/// No description provided for @labelsNoneSet.
///
/// In en, this message translates to:
/// **'No labels'**
String get labelsNoneSet;
/// No description provided for @labelsCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 label} other{{count} labels}}'**
String labelsCount(int count);
/// No description provided for @labelAdd.
///
/// In en, this message translates to:
/// **'New label'**
String get labelAdd;
/// No description provided for @labelEdit.
///
/// In en, this message translates to:
/// **'Edit label'**
String get labelEdit;
/// No description provided for @labelDelete.
///
/// In en, this message translates to:
/// **'Delete label'**
String get labelDelete;
/// No description provided for @labelDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the label?'**
String get labelDeleteTitle;
/// No description provided for @labelDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{name}\" is taken off every task that has it.'**
String labelDeleteMessage(String name);
/// No description provided for @nameLabel.
///
/// In en, this message translates to:
/// **'Name'**
String get nameLabel;
/// No description provided for @save.
///
/// In en, this message translates to:
/// **'Save'**
String get save;
/// No description provided for @overdue.
///
/// In en, this message translates to:
/// **'Overdue'**
String get overdue;
/// No description provided for @reschedule.
///
/// In en, this message translates to:
/// **'Reschedule'**
String get reschedule;
/// No description provided for @priorityTitle.
///
/// In en, this message translates to:
/// **'Priority'**
String get priorityTitle;
/// No description provided for @priority1.
///
/// In en, this message translates to:
/// **'Priority 1'**
String get priority1;
/// No description provided for @priority2.
///
/// In en, this message translates to:
/// **'Priority 2'**
String get priority2;
/// No description provided for @priority3.
///
/// In en, this message translates to:
/// **'Priority 3'**
String get priority3;
/// No description provided for @priority4.
///
/// In en, this message translates to:
/// **'No priority'**
String get priority4;
/// No description provided for @projectsTitle.
///
/// In en, this message translates to:
/// **'Projects'**
String get projectsTitle;
/// No description provided for @projectsNone.
///
/// In en, this message translates to:
/// **'No projects of your own yet.'**
String get projectsNone;
/// No description provided for @projectAdd.
///
/// In en, this message translates to:
/// **'New project'**
String get projectAdd;
/// No description provided for @projectEdit.
///
/// In en, this message translates to:
/// **'Edit project'**
String get projectEdit;
/// No description provided for @projectDelete.
///
/// In en, this message translates to:
/// **'Delete project'**
String get projectDelete;
/// No description provided for @projectDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the project?'**
String get projectDeleteTitle;
/// No description provided for @projectDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{name}\" is deleted with its sections and tasks, on all your devices.'**
String projectDeleteMessage(String name);
/// No description provided for @projectEmptyTitle.
///
/// In en, this message translates to:
/// **'Nothing to do here'**
String get projectEmptyTitle;
/// No description provided for @projectEmptyHint.
///
/// In en, this message translates to:
/// **'Add a task with the + button.'**
String get projectEmptyHint;
/// No description provided for @projectGone.
///
/// In en, this message translates to:
/// **'This project was deleted.'**
String get projectGone;
/// No description provided for @projectMenu.
///
/// In en, this message translates to:
/// **'More'**
String get projectMenu;
/// No description provided for @projectPickTitle.
///
/// In en, this message translates to:
/// **'Project'**
String get projectPickTitle;
/// No description provided for @sectionAdd.
///
/// In en, this message translates to:
/// **'New section'**
String get sectionAdd;
/// No description provided for @sectionEdit.
///
/// In en, this message translates to:
/// **'Rename section'**
String get sectionEdit;
/// No description provided for @sectionDelete.
///
/// In en, this message translates to:
/// **'Delete section'**
String get sectionDelete;
/// No description provided for @sectionDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the section?'**
String get sectionDeleteTitle;
/// No description provided for @sectionDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{name}\" is deleted with its tasks.'**
String sectionDeleteMessage(String name);
/// No description provided for @sectionMenu.
///
/// In en, this message translates to:
/// **'Section'**
String get sectionMenu;
/// No description provided for @subTasks.
///
/// In en, this message translates to:
/// **'Sub-tasks'**
String get subTasks;
/// No description provided for @subTasksDone.
///
/// In en, this message translates to:
/// **'Sub-tasks {done}/{count}'**
String subTasksDone(int done, int count);
/// No description provided for @subTaskAdd.
///
/// In en, this message translates to:
/// **'Add sub-task'**
String get subTaskAdd;
/// No description provided for @taskTitle.
///
/// In en, this message translates to:
/// **'Task'**
String get taskTitle;
/// No description provided for @taskAddTitle.
///
/// In en, this message translates to:
/// **'Add task'**
String get taskAddTitle;
/// No description provided for @taskAdd.
///
/// In en, this message translates to:
/// **'Add'**
String get taskAdd;
/// No description provided for @taskAddOn.
///
/// In en, this message translates to:
/// **'Add a task on {day}'**
String taskAddOn(String day);
/// No description provided for @taskTitleHint.
///
/// In en, this message translates to:
/// **'What\'s to be done?'**
String get taskTitleHint;
/// No description provided for @taskDescription.
///
/// In en, this message translates to:
/// **'Description'**
String get taskDescription;
/// No description provided for @taskDescriptionHint.
///
/// In en, this message translates to:
/// **'Description'**
String get taskDescriptionHint;
/// No description provided for @taskComplete.
///
/// In en, this message translates to:
/// **'Mark as done'**
String get taskComplete;
/// No description provided for @taskReopen.
///
/// In en, this message translates to:
/// **'Mark as not done'**
String get taskReopen;
/// No description provided for @taskCompleted.
///
/// In en, this message translates to:
/// **'Task done'**
String get taskCompleted;
/// No description provided for @taskDelete.
///
/// In en, this message translates to:
/// **'Delete task'**
String get taskDelete;
/// No description provided for @taskDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the task?'**
String get taskDeleteTitle;
/// No description provided for @taskDeleteMessage.
///
/// In en, this message translates to:
/// **'\"{title}\" is deleted on all your devices.'**
String taskDeleteMessage(String title);
/// No description provided for @taskDeleteWithSubTasks.
///
/// In en, this message translates to:
/// **'\"{title}\" is deleted with its {count, plural, =1{sub-task} other{{count} sub-tasks}}, on all your devices.'**
String taskDeleteWithSubTasks(String title, int count);
/// No description provided for @taskGone.
///
/// In en, this message translates to:
/// **'This task was deleted.'**
String get taskGone;
/// No description provided for @todayEmptyTitle.
///
/// In en, this message translates to:
/// **'Nothing due today'**
String get todayEmptyTitle;
/// No description provided for @todayEmptyHint.
///
/// In en, this message translates to:
/// **'Tasks dated today, and those overdue, show here.'**
String get todayEmptyHint;
/// No description provided for @lockedRows.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 item doesn\'t open on this device.} other{{count} items don\'t open on this device.}}'**
String lockedRows(int count);
/// No description provided for @inboxMissingTitle.
///
/// In en, this message translates to:
/// **'No Inbox yet'**
String get inboxMissingTitle;
/// No description provided for @inboxMissingHint.
///
/// In en, this message translates to:
/// **'Your Inbox comes with the next sync, or once this device holds your account\'s keys.'**
String get inboxMissingHint;
}
class _AppLocalizationsDelegate

View file

@ -41,22 +41,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get shellTabBrowse => 'Übersicht';
@override
String get todayLaterTitle => 'Hier stehen die Aufgaben von heute';
@override
String get upcomingLaterTitle => 'Hier stehen die nächsten Tage';
@override
String get inboxLaterTitle => 'Hier stehen Aufgaben ohne Projekt';
@override
String get browseLaterTitle => 'Hier stehen deine Projekte und Labels';
@override
String get tasksLaterHint =>
'Aufgaben kommen mit einer späteren Version von Halkyon Todo.';
@override
String get settingsTitle => 'Einstellungen';
@ -64,4 +48,278 @@ class AppLocalizationsDe extends AppLocalizations {
String settingsVersion(String version, String build) {
return 'Version $version (Build $build)';
}
@override
String get clear => 'Entfernen';
@override
String get colorLabel => 'Farbe';
@override
String get completedShow => 'Erledigte zeigen';
@override
String get completedHide => 'Erledigte ausblenden';
@override
String get dateTitle => 'Datum';
@override
String get dateNextWeek => 'Nächste Woche';
@override
String get dateWeekend => 'Dieses Wochenende';
@override
String get datePick => 'Tag wählen';
@override
String get dateTime => 'Uhrzeit';
@override
String get dateNone => 'Kein Datum';
@override
String get dateNoneYet => 'Kein Datum';
@override
String get dayToday => 'Heute';
@override
String get dayTomorrow => 'Morgen';
@override
String get dayYesterday => 'Gestern';
@override
String dayHeadingNamed(String date, String name) {
return '$date · $name';
}
@override
String deadlineOn(String day) {
return 'Frist $day';
}
@override
String get deadlineNone => 'Keine Frist';
@override
String get keysMissing =>
'Dieses Gerät kann nichts speichern: Es hat die Schlüssel deines Kontos nicht. Melde dich mit deinem Passwort oder deiner Wiederherstellungsphrase an, um sie hinzuzufügen.';
@override
String get labelsTitle => 'Labels';
@override
String get labelsNone => 'Noch keine Labels.';
@override
String get labelsNoneSet => 'Keine Labels';
@override
String labelsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Labels',
one: '1 Label',
);
return '$_temp0';
}
@override
String get labelAdd => 'Neues Label';
@override
String get labelEdit => 'Label bearbeiten';
@override
String get labelDelete => 'Label löschen';
@override
String get labelDeleteTitle => 'Label löschen?';
@override
String labelDeleteMessage(String name) {
return '„$name“ wird von jeder Aufgabe entfernt, die es hat.';
}
@override
String get nameLabel => 'Name';
@override
String get save => 'Speichern';
@override
String get overdue => 'Überfällig';
@override
String get reschedule => 'Verschieben';
@override
String get priorityTitle => 'Priorität';
@override
String get priority1 => 'Priorität 1';
@override
String get priority2 => 'Priorität 2';
@override
String get priority3 => 'Priorität 3';
@override
String get priority4 => 'Keine Priorität';
@override
String get projectsTitle => 'Projekte';
@override
String get projectsNone => 'Noch keine eigenen Projekte.';
@override
String get projectAdd => 'Neues Projekt';
@override
String get projectEdit => 'Projekt bearbeiten';
@override
String get projectDelete => 'Projekt löschen';
@override
String get projectDeleteTitle => 'Projekt löschen?';
@override
String projectDeleteMessage(String name) {
return '„$name“ wird mit seinen Abschnitten und Aufgaben auf all deinen Geräten gelöscht.';
}
@override
String get projectEmptyTitle => 'Hier ist nichts zu tun';
@override
String get projectEmptyHint => 'Füge mit dem Plus eine Aufgabe hinzu.';
@override
String get projectGone => 'Dieses Projekt wurde gelöscht.';
@override
String get projectMenu => 'Mehr';
@override
String get projectPickTitle => 'Projekt';
@override
String get sectionAdd => 'Neuer Abschnitt';
@override
String get sectionEdit => 'Abschnitt umbenennen';
@override
String get sectionDelete => 'Abschnitt löschen';
@override
String get sectionDeleteTitle => 'Abschnitt löschen?';
@override
String sectionDeleteMessage(String name) {
return '„$name“ wird mit seinen Aufgaben gelöscht.';
}
@override
String get sectionMenu => 'Abschnitt';
@override
String get subTasks => 'Unteraufgaben';
@override
String subTasksDone(int done, int count) {
return 'Unteraufgaben $done/$count';
}
@override
String get subTaskAdd => 'Unteraufgabe hinzufügen';
@override
String get taskTitle => 'Aufgabe';
@override
String get taskAddTitle => 'Aufgabe hinzufügen';
@override
String get taskAdd => 'Hinzufügen';
@override
String taskAddOn(String day) {
return 'Aufgabe für $day hinzufügen';
}
@override
String get taskTitleHint => 'Was ist zu tun?';
@override
String get taskDescription => 'Beschreibung';
@override
String get taskDescriptionHint => 'Beschreibung';
@override
String get taskComplete => 'Als erledigt markieren';
@override
String get taskReopen => 'Als unerledigt markieren';
@override
String get taskCompleted => 'Aufgabe erledigt';
@override
String get taskDelete => 'Aufgabe löschen';
@override
String get taskDeleteTitle => 'Aufgabe löschen?';
@override
String taskDeleteMessage(String title) {
return '„$title“ wird auf all deinen Geräten gelöscht.';
}
@override
String taskDeleteWithSubTasks(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'seinen $count Unteraufgaben',
one: 'seiner Unteraufgabe',
);
return '„$title“ wird mit $_temp0 auf all deinen Geräten gelöscht.';
}
@override
String get taskGone => 'Diese Aufgabe wurde gelöscht.';
@override
String get todayEmptyTitle => 'Heute ist nichts fällig';
@override
String get todayEmptyHint =>
'Aufgaben mit dem heutigen Datum und überfällige erscheinen hier.';
@override
String lockedRows(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Einträge lassen sich auf diesem Gerät nicht öffnen.',
one: '1 Eintrag lässt sich auf diesem Gerät nicht öffnen.',
);
return '$_temp0';
}
@override
String get inboxMissingTitle => 'Noch kein Eingang';
@override
String get inboxMissingHint =>
'Dein Eingang kommt mit dem nächsten Abgleich, oder sobald dieses Gerät die Schlüssel deines Kontos hat.';
}

View file

@ -41,22 +41,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get shellTabBrowse => 'Browse';
@override
String get todayLaterTitle => 'Today\'s tasks come here';
@override
String get upcomingLaterTitle => 'The days ahead come here';
@override
String get inboxLaterTitle => 'Tasks without a project come here';
@override
String get browseLaterTitle => 'Your projects and labels come here';
@override
String get tasksLaterHint =>
'Tasks come with a later version of Halkyon Todo.';
@override
String get settingsTitle => 'Settings';
@ -64,6 +48,280 @@ class AppLocalizationsEn extends AppLocalizations {
String settingsVersion(String version, String build) {
return 'Version $version (build $build)';
}
@override
String get clear => 'Clear';
@override
String get colorLabel => 'Colour';
@override
String get completedShow => 'Show done tasks';
@override
String get completedHide => 'Hide done tasks';
@override
String get dateTitle => 'Date';
@override
String get dateNextWeek => 'Next week';
@override
String get dateWeekend => 'This weekend';
@override
String get datePick => 'Pick a day';
@override
String get dateTime => 'Time';
@override
String get dateNone => 'No date';
@override
String get dateNoneYet => 'No date';
@override
String get dayToday => 'Today';
@override
String get dayTomorrow => 'Tomorrow';
@override
String get dayYesterday => 'Yesterday';
@override
String dayHeadingNamed(String date, String name) {
return '$date · $name';
}
@override
String deadlineOn(String day) {
return 'Deadline $day';
}
@override
String get deadlineNone => 'No deadline';
@override
String get keysMissing =>
'This device can\'t save: it doesn\'t hold your account\'s keys. Sign in with your password or recovery phrase to add them.';
@override
String get labelsTitle => 'Labels';
@override
String get labelsNone => 'No labels yet.';
@override
String get labelsNoneSet => 'No labels';
@override
String labelsCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count labels',
one: '1 label',
);
return '$_temp0';
}
@override
String get labelAdd => 'New label';
@override
String get labelEdit => 'Edit label';
@override
String get labelDelete => 'Delete label';
@override
String get labelDeleteTitle => 'Delete the label?';
@override
String labelDeleteMessage(String name) {
return '\"$name\" is taken off every task that has it.';
}
@override
String get nameLabel => 'Name';
@override
String get save => 'Save';
@override
String get overdue => 'Overdue';
@override
String get reschedule => 'Reschedule';
@override
String get priorityTitle => 'Priority';
@override
String get priority1 => 'Priority 1';
@override
String get priority2 => 'Priority 2';
@override
String get priority3 => 'Priority 3';
@override
String get priority4 => 'No priority';
@override
String get projectsTitle => 'Projects';
@override
String get projectsNone => 'No projects of your own yet.';
@override
String get projectAdd => 'New project';
@override
String get projectEdit => 'Edit project';
@override
String get projectDelete => 'Delete project';
@override
String get projectDeleteTitle => 'Delete the project?';
@override
String projectDeleteMessage(String name) {
return '\"$name\" is deleted with its sections and tasks, on all your devices.';
}
@override
String get projectEmptyTitle => 'Nothing to do here';
@override
String get projectEmptyHint => 'Add a task with the + button.';
@override
String get projectGone => 'This project was deleted.';
@override
String get projectMenu => 'More';
@override
String get projectPickTitle => 'Project';
@override
String get sectionAdd => 'New section';
@override
String get sectionEdit => 'Rename section';
@override
String get sectionDelete => 'Delete section';
@override
String get sectionDeleteTitle => 'Delete the section?';
@override
String sectionDeleteMessage(String name) {
return '\"$name\" is deleted with its tasks.';
}
@override
String get sectionMenu => 'Section';
@override
String get subTasks => 'Sub-tasks';
@override
String subTasksDone(int done, int count) {
return 'Sub-tasks $done/$count';
}
@override
String get subTaskAdd => 'Add sub-task';
@override
String get taskTitle => 'Task';
@override
String get taskAddTitle => 'Add task';
@override
String get taskAdd => 'Add';
@override
String taskAddOn(String day) {
return 'Add a task on $day';
}
@override
String get taskTitleHint => 'What\'s to be done?';
@override
String get taskDescription => 'Description';
@override
String get taskDescriptionHint => 'Description';
@override
String get taskComplete => 'Mark as done';
@override
String get taskReopen => 'Mark as not done';
@override
String get taskCompleted => 'Task done';
@override
String get taskDelete => 'Delete task';
@override
String get taskDeleteTitle => 'Delete the task?';
@override
String taskDeleteMessage(String title) {
return '\"$title\" is deleted on all your devices.';
}
@override
String taskDeleteWithSubTasks(String title, int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count sub-tasks',
one: 'sub-task',
);
return '\"$title\" is deleted with its $_temp0, on all your devices.';
}
@override
String get taskGone => 'This task was deleted.';
@override
String get todayEmptyTitle => 'Nothing due today';
@override
String get todayEmptyHint =>
'Tasks dated today, and those overdue, show here.';
@override
String lockedRows(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count items don\'t open on this device.',
one: '1 item doesn\'t open on this device.',
);
return '$_temp0';
}
@override
String get inboxMissingTitle => 'No Inbox yet';
@override
String get inboxMissingHint =>
'Your Inbox comes with the next sync, or once this device holds your account\'s keys.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -5,6 +5,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_todo_flutter/src/app/todo_router.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -90,6 +91,12 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators),
),
// The person's projects, tasks and labels, for every tab and
// sheet.
BlocProvider(
create: (context) =>
TodoCubit(PersonScope.of<TodoPerson>(context))..open(),
),
// Where sync stands: Settings' card and the dot on the cog.
BlocProvider(
create: (context) =>

View file

@ -13,6 +13,9 @@ abstract final class TodoPaths() {
/// Browse: the projects and labels, and the cog to Settings.
static const browse = '/browse';
/// A project's screen, in Browse.
static String project(String id) => '/browse/projects/$id';
/// Settings, over the tabs.
static const settings = '/settings';
}

View file

@ -1,5 +1,6 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_screen.dart';
import 'package:halkyon_todo_flutter/src/settings/settings_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
import 'package:halkyon_todo_flutter/src/tabs/inbox_tab.dart';
@ -49,6 +50,13 @@ GoRouter buildTodoRouter() => GoRouter(
GoRoute(
path: TodoPaths.browse,
builder: (context, state) => const BrowseTab(),
routes: [
GoRoute(
path: 'projects/:id',
builder: (context, state) =>
ProjectScreen(projectId: state.pathParameters['id']!),
),
],
),
],
),

View file

@ -2,7 +2,7 @@ import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The schema of a label's payload this app writes and reads.
const labelSchemaVersion = 1;
@ -21,7 +21,7 @@ LabelContent? decodeLabel(ByteData payload) => decodeJson(
name: 'Label',
read: (json) => LabelContent(
name: json['name']! as String,
color: paletteColorNamed(json['color'] as String?),
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
),
);

View file

@ -24,11 +24,7 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
name: 'Project',
read: (json) => ProjectContent(
name: json['name']! as String,
color: paletteColorNamed(json['color'] as String?),
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
),
);
/// The palette's colour named [name]; none for a name it doesn't know.
PaletteColor? paletteColorNamed(String? name) =>
PaletteColor.values.where((color) => color.name == name).firstOrNull;

View file

@ -0,0 +1,136 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a project's name and colour (ADR 0033, D9), [current]'s to
/// change, or a new one's; null where the person changed their mind.
Future<ProjectContent?> editProject(
BuildContext context, {
ProjectContent? current,
}) async {
final l10n = AppLocalizations.of(context);
final named = await _nameAndColor(
context,
title: current == null ? l10n.projectAdd : l10n.projectEdit,
name: current?.name ?? '',
color: current?.color ?? PaletteColor.cobalt,
);
return switch (named) {
(:final name, :final color) => ProjectContent(
name: name,
color: color,
position: current?.position ?? 0,
),
null => null,
};
}
/// Asks for a label's name and colour, [current]'s to change, or a new
/// one's; null where the person changed their mind.
Future<LabelContent?> editLabel(
BuildContext context, {
LabelContent? current,
}) async {
final l10n = AppLocalizations.of(context);
final named = await _nameAndColor(
context,
title: current == null ? l10n.labelAdd : l10n.labelEdit,
name: current?.name ?? '',
color: current?.color ?? PaletteColor.carrot,
);
return switch (named) {
(:final name, :final color) => LabelContent(
name: name,
color: color,
position: current?.position ?? 0,
),
null => null,
};
}
/// Asks for a section's name, [current]'s to change; null where the person
/// changed their mind.
Future<SectionContent?> editSection(
BuildContext context,
SectionContent current,
) async {
final l10n = AppLocalizations.of(context);
final named = await _nameAndColor(
context,
title: current.name.isEmpty ? l10n.sectionAdd : l10n.sectionEdit,
name: current.name,
color: null,
);
return switch (named) {
(:final name, color: _) => current.copyWith(name: name),
null => null,
};
}
Future<({String name, PaletteColor? color})?> _nameAndColor(
BuildContext context, {
required String title,
required String name,
required PaletteColor? color,
}) {
final l10n = AppLocalizations.of(context);
return showDialog<({String name, PaletteColor? color})>(
context: context,
builder: (context) {
final controller = TextEditingController(text: name);
var chosen = color;
void done() {
final text = controller.text.trim();
if (text.isNotEmpty) {
Navigator.of(context).pop((name: text, color: chosen));
}
}
return StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(title),
// As wide as a dialog's field usually is: the colour picker
// takes the width it is given.
content: SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.nameLabel),
onSubmitted: (_) => done(),
),
if (chosen case final current?) ...[
const SizedBox(height: 16),
Text(
l10n.colorLabel,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
PaletteColorPicker(
selected: current,
onChanged: (picked) => setState(() => chosen = picked),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(onPressed: done, child: Text(l10n.save)),
],
),
);
},
);
}

View file

@ -0,0 +1,140 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_dialogs.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_tasks.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
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.
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;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final projectId = widget.projectId;
return TabScreen(
title: widget.title,
actions: [
if (projectId != null)
PopupMenuButton<VoidCallback>(
tooltip: l10n.projectMenu,
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () => setState(() => _completed = !_completed),
child: Text(
_completed ? l10n.completedHide : l10n.completedShow,
),
),
PopupMenuItem(
value: () => addSection(context, projectId),
child: Text(l10n.sectionAdd),
),
...?widget.menu?.call(context),
],
),
],
floatingActionButton: projectId == null
? null
: AddTaskButton(onPressed: () => showQuickAddIn(context, projectId)),
body: Column(
children: [
const KeysResetBanner(),
Expanded(
child: switch (projectId) {
final id? => ProjectTasks(projectId: id, completed: _completed),
// No Inbox yet: made by the first device holding the
// account's keys, or still on its way here.
null
when context.select<TodoCubit, bool>(
(cubit) => cubit.state.loaded,
) =>
Center(
child: EmptyListMessage(
icon: LucideIcons.inbox,
title: l10n.inboxMissingTitle,
hint: l10n.inboxMissingHint,
),
),
null => const Center(child: AppLoadingIndicator()),
},
),
],
),
);
}
}
/// A project of the person's own, from Browse (ADR 0033, D2): its tasks as
/// the Inbox's, and in its menu editing its name and colour and deleting it
/// with its sections and tasks, which asks first. A project gone (deleted
/// on another device) goes back.
class const ProjectScreen({required final String projectId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final project = state.projectOf(projectId);
if (project == null) {
return TabScreen(
title: '',
body: Center(child: Text(state.loaded ? l10n.projectGone : '')),
);
}
final content = project.content!;
return ProjectScreenBody(
title: project.inbox ? l10n.shellTabInbox : content.name,
projectId: projectId,
menu: project.inbox
? null
: (context) => [
PopupMenuItem(
value: () async {
final todo = PersonScope.of<TodoPerson>(context);
if (await editProject(context, current: content)
case final edited?) {
await todo.projects.update(projectId, edited);
}
},
child: Text(l10n.projectEdit),
),
PopupMenuItem(
value: () async {
final todo = PersonScope.of<TodoPerson>(context);
final router = GoRouter.of(context);
if (await confirmDestructiveAction(
context,
title: l10n.projectDeleteTitle,
message: l10n.projectDeleteMessage(content.name),
confirmLabel: l10n.projectDelete,
)) {
await todo.deleteProject(projectId);
router.pop();
}
},
child: Text(l10n.projectDelete),
),
],
);
}
}

View file

@ -0,0 +1,169 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/project_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_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_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 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.
class const ProjectTasks({
required final String projectId,
final bool completed = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
if (!state.loaded) {
return const Center(child: AppLoadingIndicator());
}
final loose = tasksIn(state.tasks, projectId, completed: completed);
final sections = state.sectionsOf(projectId);
if (loose.isEmpty &&
sections.isEmpty &&
!state.tasks.any((t) => t.content.projectId == projectId)) {
return Center(
child: EmptyListMessage(
icon: LucideIcons.listTodo,
title: l10n.projectEmptyTitle,
hint: l10n.projectEmptyHint,
),
);
}
return ListView(
padding: EdgeInsets.only(
bottom: 88 + FloatingNavigationBar.coverOf(context),
),
children: [
for (final task in loose) TaskTile(task: task),
for (final section in sections) ..._section(context, state, section),
],
);
}
List<Widget> _section(
BuildContext context,
TodoState state,
StoredSection section,
) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final content = section.content!;
final tasks = tasksIn(
state.tasks,
projectId,
sectionId: section.id,
completed: completed,
);
return [
const Divider(height: 24),
ListTile(
title: Text(
'${content.name} ${tasks.where((t) => !t.content.completed).length}',
style: theme.textTheme.titleMedium,
),
subtitle: content.description.isEmpty
? null
: Text(
content.description,
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: Text(l10n.taskAddTitle),
),
PopupMenuItem(
value: _SectionAction.edit,
child: Text(l10n.sectionEdit),
),
PopupMenuItem(
value: _SectionAction.delete,
child: Text(l10n.sectionDelete),
),
],
),
),
for (final task in tasks) TaskTile(task: task),
];
}
}
enum _SectionAction() {
add,
edit,
delete,
}
/// Asks for a new section's name and adds it last in [projectId].
Future<void> addSection(BuildContext context, String projectId) async {
final todo = PersonScope.of<TodoPerson>(context);
final sections = context.read<TodoCubit>().state.sectionsOf(projectId);
final added = await editSection(
context,
SectionContent(
name: '',
projectId: projectId,
position: sections.isEmpty ? 0 : sections.last.content!.position + 1,
),
);
if (added != null) {
await todo.sections.add(added);
}
}
/// Quick add into [projectId], above its sections.
Future<void> showQuickAddIn(BuildContext context, String projectId) =>
showQuickAdd(context, projectId: projectId);
/// The button adding a task, above the navigation bar (ADR 0033, D2).
class const AddTaskButton({required final VoidCallback onPressed, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) => FloatingActionButton(
// Every tab keeps its own, alive beside the others: no hero flies.
heroTag: null,
tooltip: AppLocalizations.of(context).taskAddTitle,
onPressed: onPressed,
child: const Icon(LucideIcons.plus),
);
}

View file

@ -0,0 +1,123 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// Everything of the person's that Todo's screens show, as it stands on
/// this device (ADR 0033, D8): their projects (the Inbox first), sections,
/// tasks and labels, those that open; and how many rows of each kind don't.
final class const TodoState({
final bool loaded = false,
final List<StoredProject> projects = const [],
final List<StoredSection> sections = const [],
final List<OpenTask> tasks = const [],
final List<StoredLabel> labels = const [],
/// How many rows don't open on this device: another device of the
/// person's wrote them before this one held the keys, or the instance
/// made them up.
final int locked = 0,
}) with Equatable {
/// The Inbox's ID; null before the Inbox is there.
String? get inboxId => projects
.where((project) => project.inbox)
.map((project) => project.id)
.firstOrNull;
/// The project [id], opened; null for one gone or locked.
StoredProject? projectOf(String id) => projects
.where((project) => project.id == id && project.content != null)
.firstOrNull;
/// The sections of the project [projectId] that open, in their order.
List<StoredSection> sectionsOf(String projectId) => [
for (final section in sections)
if (section.content?.projectId == projectId) section,
]..sort((a, b) => a.content!.position.compareTo(b.content!.position));
/// The task [id]; null for one gone or locked.
OpenTask? taskOf(String id) =>
tasks.where((task) => task.id == id).firstOrNull;
@override
List<Object?> get props => [
loaded,
projects,
sections,
tasks,
labels,
locked,
];
}
/// Reads the person's data into [TodoState], and again whenever it changes
/// here or a sync brings changes from their other devices.
class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
this : super(const TodoState());
final _subscriptions = <StreamSubscription<void>>[];
late final _reads = CoalescingRunner(
_read,
onError: (error, stackTrace) =>
log("The person's tasks not read: $error", name: 'Todo'),
);
/// Reads it all, and follows every change.
void open() {
for (final changes in [
_todo.projects.changes,
_todo.sections.changes,
_todo.tasks.changes,
_todo.labels.changes,
]) {
_subscriptions.add(changes.listen((_) => unawaited(_reads.run())));
}
unawaited(_reads.run());
}
Future<void> _read() async {
final projects = await _todo.projects.all();
final sections = await _todo.sections.all();
final tasks = await _todo.tasks.all();
final labels = await _todo.labels.all();
if (isClosed) {
return;
}
emit(
TodoState(
loaded: true,
projects: projects,
sections: [
for (final section in sections)
if (section.content != null) section,
],
tasks: openTasks(tasks),
labels: [
for (final label in labels)
if (label.content != null) label,
],
locked: [
...projects.map((row) => row.content),
...sections.map((row) => row.content),
...tasks.map((row) => row.content),
...labels.map((row) => row.content),
].where((content) => content == null).length,
),
);
}
@override
Future<void> close() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await super.close();
}
}

View file

@ -0,0 +1,120 @@
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A task that opens here: its ID and what it is.
typedef OpenTask = ({String id, TaskContent content});
/// The tasks of [all] that open here, locked ones left out.
List<OpenTask> openTasks(Iterable<StoredTask> all) => [
for (final task in all)
if (task.content case final content?) (id: task.id, content: content),
];
/// Orders tasks as a list shows them: by their position, ties by when they
/// were made.
int byPosition(OpenTask a, OpenTask b) {
final position = a.content.position.compareTo(b.content.position);
return position != 0 ? position : a.id.compareTo(b.id);
}
/// Orders tasks as a day shows them (ADR 0033, D8): those with a time first,
/// by it, then by priority, then by their position.
int byTime(OpenTask a, OpenTask b) {
final (at, bt) = (a.content.date?.time, b.content.date?.time);
if (at != bt) {
if (at == null) {
return 1;
}
if (bt == null) {
return -1;
}
return at.compareTo(bt);
}
final priority = a.content.priority.index.compareTo(b.content.priority.index);
return priority != 0 ? priority : byPosition(a, b);
}
/// The tasks of the project [projectId] directly in it (no sub-tasks), in
/// the section [sectionId] (none: above the sections), done ones only where
/// [completed], in their order.
List<OpenTask> tasksIn(
List<OpenTask> tasks,
String projectId, {
String? sectionId,
bool completed = false,
}) => [
for (final task in tasks)
if (task.content.projectId == projectId &&
task.content.parentId == null &&
task.content.sectionId == sectionId &&
(completed || !task.content.completed))
task,
]..sort(byPosition);
/// The sub-tasks of [parentId], in their order, done ones too.
List<OpenTask> subTasksOf(List<OpenTask> tasks, String parentId) => [
for (final task in tasks)
if (task.content.parentId == parentId) task,
]..sort(byPosition);
/// What Today shows on [today] (ADR 0033, D8): the `overdue` tasks not done,
/// dated before it, oldest first; and `due` ones, dated on it, done ones too
/// where [completed].
({List<OpenTask> overdue, List<OpenTask> due}) todayOf(
List<OpenTask> tasks,
Day today, {
bool completed = false,
}) {
final overdue = <OpenTask>[];
final due = <OpenTask>[];
for (final task in tasks) {
final day = task.content.date?.day;
if (day == null) {
continue;
}
if (day < today && !task.content.completed) {
overdue.add(task);
} else if (day == today && (completed || !task.content.completed)) {
due.add(task);
}
}
overdue.sort((a, b) {
final days = a.content.date!.day.compareTo(b.content.date!.day);
return days != 0 ? days : byTime(a, b);
});
return (overdue: overdue, due: due..sort(byTime));
}
/// What Upcoming shows from [today] on (ADR 0033, D8): the overdue tasks,
/// then each of the [days] days with its tasks (none for a day without),
/// done ones too where [completed].
({List<OpenTask> overdue, List<(Day, List<OpenTask>)> days}) upcomingOf(
List<OpenTask> tasks,
Day today, {
int days = 14,
bool completed = false,
}) {
final byDay = <Day, List<OpenTask>>{
for (var i = 0; i < days; i++) today.plus(i): [],
};
for (final task in tasks) {
if (task.content.date?.day case final day?
when byDay.containsKey(day) && (completed || !task.content.completed)) {
byDay[day]!.add(task);
}
}
return (
overdue: todayOf(tasks, today).overdue,
days: [
for (final MapEntry(:key, :value) in byDay.entries)
(key, value..sort(byTime)),
],
);
}
/// How many of [tasks] in the project [projectId] are still to be done,
/// sub-tasks counted.
int openCountIn(List<OpenTask> tasks, String projectId) => tasks
.where(
(task) => task.content.projectId == projectId && !task.content.completed,
)
.length;

View file

@ -0,0 +1,133 @@
import 'package:clock/clock.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.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';
/// A date chosen ([TaskDate] or none, to clear it); null from [pickTaskDate]
/// where the person changed their mind.
typedef DateChoice = ({TaskDate? date});
/// Asks for a task's date (ADR 0033, D8): today, tomorrow, the coming
/// weekend, next week (the day the language starts it on), a day from the
/// calendar, with a time where the person adds one; or none, where
/// [current] has one.
Future<DateChoice?> pickTaskDate(
BuildContext context, {
TaskDate? current,
}) => showAdaptiveSheet<DateChoice>(
context,
builder: (context) => _DateSheet(current: current),
);
/// Asks for a deadline, a day alone: none to clear it, null where the
/// person changed their mind.
Future<({Day? day})?> pickDeadline(BuildContext context, {Day? current}) async {
final today = Day.of(clock.now());
final picked = await showDatePicker(
context: context,
initialDate: (current ?? today).toDate(),
firstDate: DateTime(today.year - 5),
lastDate: DateTime(today.year + 20),
);
return picked == null ? null : (day: Day.of(picked));
}
class const _DateSheet({required final TaskDate? current})
extends StatefulWidget {
@override
State<_DateSheet> createState() => _DateSheetState();
}
class _DateSheetState() extends State<_DateSheet> {
late int? _time = widget.current?.time;
late final Duration? _duration = widget.current?.duration;
void _choose(Day day) => Navigator.of(
context,
).pop<DateChoice>((date: TaskDate(day, time: _time, duration: _duration)));
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final today = Day.of(clock.now());
final firstDay = MaterialLocalizations.of(context).firstDayOfWeekIndex;
// DateTime's weekdays run 1 (Monday) to 7, Material's 0 (Sunday) to 6.
int until(int weekday) => (weekday - today.toDate().weekday + 7) % 7;
final nextWeek = today.plus(
switch (until(firstDay == 0 ? 7 : firstDay)) {
0 => 7,
final days => days,
},
);
final weekend = today.plus(until(DateTime.saturday));
Widget option(IconData icon, String label, Day day) => ListTile(
leading: Icon(icon),
title: Text(label),
trailing: Text(dayText(context, day, today)),
onTap: () => _choose(day),
);
return SheetScaffold(
title: l10n.dateTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
option(LucideIcons.calendarCheck, l10n.dayToday, today),
option(LucideIcons.sun, l10n.dayTomorrow, today.plus(1)),
if (weekend != today && weekend != today.plus(1))
option(LucideIcons.sofa, l10n.dateWeekend, weekend),
option(LucideIcons.calendarArrowUp, l10n.dateNextWeek, nextWeek),
ListTile(
leading: const Icon(LucideIcons.calendarDays),
title: Text(l10n.datePick),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: (widget.current?.day ?? today).toDate(),
firstDate: DateTime(today.year - 5),
lastDate: DateTime(today.year + 20),
);
if (picked != null && context.mounted) {
_choose(Day.of(picked));
}
},
),
ListTile(
leading: const Icon(LucideIcons.clock),
title: Text(l10n.dateTime),
trailing: switch (_time) {
final time? => InputChip(
label: Text(timeText(context, time)),
onDeleted: () => setState(() => _time = null),
),
null => null,
},
onTap: () async {
final picked = await showTimePicker(
context: context,
initialTime: switch (_time) {
final time? => TimeOfDay(
hour: time ~/ 60,
minute: time % 60,
),
null => const TimeOfDay(hour: 9, minute: 0),
},
);
if (picked != null) {
setState(() => _time = picked.hour * 60 + picked.minute);
}
},
),
if (widget.current != null)
ListTile(
leading: const Icon(LucideIcons.calendarX),
title: Text(l10n.dateNone),
onTap: () => Navigator.of(context).pop<DateChoice>((date: null)),
),
],
),
);
}
}

View file

@ -0,0 +1,253 @@
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/privacy/todo_keys.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/date_picker.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';
/// Quick add (ADR 0033, D8): a task's title, and chips for its date,
/// priority, project and labels, and a description where asked for. It
/// goes into [projectId] (the Inbox where none is given), [sectionId] and
/// under [parentId] where given, dated [day] where given, last among its
/// siblings. The sheet stays for the next task; going back closes it.
Future<void> showQuickAdd(
BuildContext context, {
String? projectId,
String? sectionId,
String? parentId,
Day? day,
}) => showAdaptiveSheet<void>(
context,
builder: (context) => _QuickAdd(
projectId: projectId ?? context.read<TodoCubit>().state.inboxId,
sectionId: sectionId,
parentId: parentId,
day: day,
),
);
class const _QuickAdd({
required final String? projectId,
required final String? sectionId,
required final String? parentId,
required final Day? day,
}) extends StatefulWidget {
@override
State<_QuickAdd> createState() => _QuickAddState();
}
class _QuickAddState() extends State<_QuickAdd> {
final _title = TextEditingController();
final _description = TextEditingController();
final _focus = FocusNode();
late TaskDate? _date = switch (widget.day) {
final day? => TaskDate(day),
null => null,
};
var _priority = Priority.none;
late String? _projectId = widget.projectId;
late String? _sectionId = widget.sectionId;
var _labelIds = <String>[];
var _describing = false;
@override
void dispose() {
_title.dispose();
_description.dispose();
_focus.dispose();
super.dispose();
}
Future<void> _add() async {
final l10n = AppLocalizations.of(context);
final title = _title.text.trim();
final projectId = _projectId;
if (title.isEmpty || projectId == null) {
return;
}
final state = context.read<TodoCubit>().state;
final siblings = state.tasks.where(
(task) =>
task.content.projectId == projectId &&
task.content.sectionId == _sectionId &&
task.content.parentId == widget.parentId,
);
final last = siblings.fold<double>(
0,
(position, task) =>
task.content.position > position ? task.content.position : position,
);
try {
await PersonScope.of<TodoPerson>(context).tasks.add(
TaskContent(
title: title,
projectId: projectId,
sectionId: _sectionId,
parentId: widget.parentId,
description: _description.text.trim(),
position: siblings.isEmpty ? 0 : last + 1,
date: _date,
priority: _priority,
labelIds: _labelIds,
),
);
} on KeysMissing {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.keysMissing)));
}
return;
}
if (!mounted) {
return;
}
// Ready for the next one, in the same place and on the same day.
_title.clear();
_description.clear();
setState(() {
_priority = Priority.none;
_labelIds = [];
});
_focus.requestFocus();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<TodoCubit>().state;
final today = Day.of(clock.now());
final project = switch (_projectId) {
final id? => state.projectOf(id),
null => null,
};
return SheetScaffold(
title: widget.parentId == null ? l10n.taskAddTitle : l10n.subTaskAdd,
hasSnackBars: true,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _title,
focusNode: _focus,
autofocus: true,
textInputAction: TextInputAction.done,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: l10n.taskTitleHint),
onSubmitted: (_) => _add(),
),
if (_describing)
TextField(
controller: _description,
minLines: 1,
maxLines: 4,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(hintText: l10n.taskDescriptionHint),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
ActionChip(
avatar: Icon(
LucideIcons.calendar,
color: switch (_date) {
final date? => dateColor(context, date.day, today),
null => null,
},
),
label: Text(switch (_date) {
final date? => dateText(context, date, today),
null => l10n.dateTitle,
}),
onPressed: () async {
if (await pickTaskDate(context, current: _date) case (
:final date,
)) {
setState(() => _date = date);
}
},
),
ActionChip(
avatar: Icon(
LucideIcons.flag,
color: priorityColor(context, _priority),
),
label: Text(
_priority == Priority.none
? l10n.priorityTitle
: priorityText(l10n, _priority),
),
onPressed: () async {
if (await pickPriority(context, _priority)
case final priority?) {
setState(() => _priority = priority);
}
},
),
if (widget.parentId == null)
ActionChip(
avatar: Icon(
project?.inbox ?? true
? LucideIcons.inbox
: LucideIcons.hash,
),
label: Text(switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
}),
onPressed: () async {
if (await pickProject(context, _projectId) case final id?) {
setState(() {
if (id != _projectId) {
_sectionId = null;
}
_projectId = id;
});
}
},
),
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(
_labelIds.isEmpty
? l10n.labelsTitle
: l10n.labelsCount(_labelIds.length),
),
onPressed: () async {
if (await pickLabels(context, _labelIds) case final labels?) {
setState(() => _labelIds = labels);
}
},
),
if (!_describing)
ActionChip(
avatar: const Icon(LucideIcons.text),
label: Text(l10n.taskDescription),
onPressed: () => setState(() => _describing = true),
),
],
),
],
),
footer: ListenableBuilder(
listenable: _title,
builder: (context, _) => FilledButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.taskAdd),
onPressed: _title.text.trim().isEmpty ? null : _add,
),
),
);
}
}

View file

@ -0,0 +1,114 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.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_texts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Asks for a priority, [current] ticked; null where the person changed
/// their mind.
Future<Priority?> pickPriority(BuildContext context, Priority current) {
final l10n = AppLocalizations.of(context);
return showDialog<Priority>(
context: context,
builder: (context) => SimpleDialog(
title: Text(l10n.priorityTitle),
children: [
for (final priority in Priority.values)
ListTile(
leading: Icon(
LucideIcons.flag,
color: priorityColor(context, priority),
),
title: Text(priorityText(l10n, priority)),
trailing: priority == current
? const Icon(LucideIcons.check)
: null,
onTap: () => Navigator.of(context).pop(priority),
),
],
),
);
}
/// Asks for a project, the Inbox first, [current] ticked; its ID, or null
/// where the person changed their mind.
Future<String?> pickProject(BuildContext context, String? current) {
final l10n = AppLocalizations.of(context);
final state = context.read<TodoCubit>().state;
return showDialog<String>(
context: context,
builder: (context) => SimpleDialog(
title: Text(l10n.projectPickTitle),
children: [
for (final project in state.projects)
if (project.content case final content?)
ListTile(
leading: Icon(
project.inbox ? LucideIcons.inbox : LucideIcons.hash,
color: switch (content.color) {
final color? => paletteText(context, color),
null => null,
},
),
title: Text(project.inbox ? l10n.shellTabInbox : content.name),
trailing: project.id == current
? const Icon(LucideIcons.check)
: null,
onTap: () => Navigator.of(context).pop(project.id),
),
],
),
);
}
/// Asks which labels a task has, [current] ticked; null where the person
/// changed their mind.
Future<List<String>?> pickLabels(BuildContext context, List<String> current) {
final l10n = AppLocalizations.of(context);
final labels = context.read<TodoCubit>().state.labels;
return showDialog<List<String>>(
context: context,
builder: (context) {
final chosen = {...current};
return StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(l10n.labelsTitle),
content: labels.isEmpty
? Text(l10n.labelsNone)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final label in labels)
CheckboxListTile(
value: chosen.contains(label.id),
title: Text(label.content!.name),
onChanged: (on) => setState(
() => on ?? false
? chosen.add(label.id)
: chosen.remove(label.id),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop([
for (final label in labels)
if (chosen.contains(label.id)) label.id,
]),
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
),
);
},
);
}

View file

@ -0,0 +1,321 @@
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/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.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_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Opens the task [id]'s sheet (ADR 0033, D8): its title and description to
/// edit, a row for each of its date, deadline, priority, project and labels,
/// its sub-tasks with one more to add, and deleting it, which asks first.
Future<void> openTaskSheet(BuildContext context, String id) =>
showAdaptiveSheet<void>(context, builder: (context) => _TaskSheet(id: id));
class const _TaskSheet({required final String id}) extends StatefulWidget {
@override
State<_TaskSheet> createState() => _TaskSheetState();
}
class _TaskSheetState() extends State<_TaskSheet> {
late final TextEditingController _title;
late final TextEditingController _description;
late final TaskRepository _tasks;
/// Both read in [initState], as [dispose] can't look them up any more.
late final TodoCubit _todo;
@override
void initState() {
super.initState();
_todo = context.read<TodoCubit>();
_tasks = PersonScope.of<TodoPerson>(context).tasks;
final content = _todo.state.taskOf(widget.id)?.content;
_title = TextEditingController(text: content?.title);
_description = TextEditingController(text: content?.description);
}
@override
void dispose() {
// What was typed is kept as the sheet closes.
unawaited(_saveText());
_title.dispose();
_description.dispose();
super.dispose();
}
TaskContent? get _content => _todo.state.taskOf(widget.id)?.content;
Future<void> _change(TaskContent Function(TaskContent content) change) async {
final content = _content;
if (content == null) {
return;
}
try {
await _tasks.update(widget.id, change(content));
} on KeysMissing {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).keysMissing)),
);
}
}
}
Future<void> _saveText() async {
final content = _content;
final title = _title.text.trim();
final description = _description.text.trim();
if (content == null ||
title.isEmpty ||
(title == content.title && description == content.description)) {
return;
}
try {
await _tasks.update(
widget.id,
content.copyWith(title: title, description: description),
);
} on KeysMissing {
// Nothing is written on a device without the keys.
}
}
/// Moves the task, with its sub-tasks, to the project [projectId].
Future<void> _move(String projectId) async {
final state = context.read<TodoCubit>().state;
await _change(
(content) =>
content.copyWith(projectId: projectId, sectionId: () => null),
);
final below = <OpenTask>[];
void collect(String id) {
for (final task in subTasksOf(state.tasks, id)) {
below.add(task);
collect(task.id);
}
}
collect(widget.id);
for (final task in below) {
await _tasks.update(
task.id,
task.content.copyWith(projectId: projectId, sectionId: () => null),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final task = state.taskOf(widget.id);
if (task == null) {
// Deleted here or on another device.
return SheetScaffold(
title: l10n.taskTitle,
body: Text(l10n.taskGone),
);
}
final content = task.content;
final today = Day.of(clock.now());
final project = state.projectOf(content.projectId);
final subTasks = subTasksOf(state.tasks, task.id);
final labels = {
for (final label in state.labels) label.id: label.content!.name,
};
final muted = theme.colorScheme.onSurfaceVariant;
Widget clear(VoidCallback onPressed) => IconButton(
tooltip: l10n.clear,
icon: const Icon(LucideIcons.x),
onPressed: onPressed,
);
return SheetScaffold(
title: switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
},
hasSnackBars: true,
action: IconButton(
tooltip: l10n.taskDelete,
icon: const Icon(LucideIcons.trash2),
onPressed: () async {
final sheet = Navigator.of(context);
if (await confirmDestructiveAction(
context,
title: l10n.taskDeleteTitle,
message: subTasks.isEmpty
? l10n.taskDeleteMessage(content.title)
: l10n.taskDeleteWithSubTasks(content.title, subTasks.length),
confirmLabel: l10n.taskDelete,
)) {
await _tasks.delete(widget.id);
sheet.pop();
}
},
),
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
TaskCheck(task: task),
Expanded(
child: TextField(
controller: _title,
style: theme.textTheme.titleLarge,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
hintText: l10n.taskTitleHint,
),
onSubmitted: (_) => _saveText(),
onTapOutside: (_) => unawaited(_saveText()),
),
),
],
),
TextField(
controller: _description,
minLines: 1,
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
prefixIcon: const Icon(LucideIcons.text),
hintText: l10n.taskDescriptionHint,
),
onTapOutside: (_) => unawaited(_saveText()),
),
ListTile(
leading: Icon(
LucideIcons.calendar,
color: switch (content.date) {
final date? => dateColor(context, date.day, today),
null => null,
},
),
title: Text(switch (content.date) {
final date? => dateText(context, date, today),
null => l10n.dateNoneYet,
}),
trailing: content.date == null
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async {
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
await _change((c) => c.copyWith(date: () => date));
}
},
),
ListTile(
leading: const Icon(LucideIcons.target),
title: Text(switch (content.deadline) {
final day? => l10n.deadlineOn(dayText(context, day, today)),
null => l10n.deadlineNone,
}),
trailing: content.deadline == null
? null
: clear(() => _change((c) => c.copyWith(deadline: () => null))),
onTap: () async {
if (await pickDeadline(context, current: content.deadline) case (
:final day,
)) {
await _change((c) => c.copyWith(deadline: () => day));
}
},
),
ListTile(
leading: Icon(
LucideIcons.flag,
color: priorityColor(context, content.priority),
),
title: Text(priorityText(l10n, content.priority)),
onTap: () async {
if (await pickPriority(context, content.priority)
case final priority?) {
await _change((c) => c.copyWith(priority: priority));
}
},
),
if (content.parentId == null)
ListTile(
leading: Icon(
project?.inbox ?? true ? LucideIcons.inbox : LucideIcons.hash,
),
title: Text(switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
}),
onTap: () async {
if (await pickProject(context, content.projectId) case final id?
when id != content.projectId) {
await _move(id);
}
},
),
ListTile(
leading: const Icon(LucideIcons.tag),
title: Text(
content.labelIds.isEmpty
? l10n.labelsNoneSet
: [
for (final id in content.labelIds) ?labels[id],
].join(', '),
),
onTap: () async {
if (await pickLabels(context, content.labelIds)
case final chosen?) {
await _change((c) => c.copyWith(labelIds: chosen));
}
},
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
subTasks.isEmpty
? l10n.subTasks
: l10n.subTasksDone(
subTasks.where((t) => t.content.completed).length,
subTasks.length,
),
style: theme.textTheme.titleSmall?.copyWith(color: muted),
),
),
for (final sub in subTasks) TaskTile(task: sub),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.subTaskAdd),
onPressed: () => showQuickAdd(
context,
projectId: content.projectId,
sectionId: content.sectionId,
parentId: task.id,
),
),
),
],
),
);
}
}

Some files were not shown because too many files have changed in this diff Show more