feat(todo): reminders on the device, Upcoming's week strip, the task pane and search (ADR 0033, D2, D5, D8)

A task with a time takes reminders, minutes before it, in its sealed
payload; each device plans the coming ones whenever the tasks change and
schedules them with the system through flutter_local_notifications
(Android, iOS, macOS, Windows; on Linux the running app shows them), at
the minute where Android allows exact alarms. Notifying is asked for
when a task first gets a reminder; signing out or switching person
cancels them. Upcoming gets its week strip: the month opening
Material's calendar, the weeks before and after, a dot under each day
with tasks; a day picked scrolls the agenda to it, and scrolling moves
the strip along. From expanded windows on, a task opens in a pane
beside the list rather than in a dialog. Search, from every tab's app
bar, finds projects by name and tasks by every word typed, in their
title, description or labels, whatever the case and accents, on the
device alone.

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 12:16:19 +01:00
commit 666e8dbcbb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
35 changed files with 2140 additions and 57 deletions

View file

@ -2,8 +2,8 @@
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Todo needs the same thing. What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Todo needs the same thing.
- **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`). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`). - **A shell on the shared packages** (decided 2026-10-19; what Todo is to do and how it looks: ADR 0033, decided 2026-11-01): signing in, the instances, the account and the look work; since 1 Nov 2026 a person's projects are kept and synced with `serverpod_offline_sync`, every row sealed (ADR 0033, D5, D6): `content_key` and `project` on the server (`deleteTodoData` deletes a person's space with them), and on the device `TodoKeys` (Train's `TrainKeys` copied with Todo's own key IDs, `todo1.…`; to be shared in halkyon_app once Train's 0.1.1 is out), `SealedRows` (`lib/src/core/sealed/`: a table's rows sealed for their table and row, under which `ProjectRepository`, the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted, `SectionRepository`, `TaskRepository`, a task deleted with its sub-tasks, and `LabelRepository`), one sync gateway per table, and `TodoPerson` opening the person's sync database, its `SyncService` and all of them, as Train's; a project or a section is deleted with its tasks (`TodoPerson.deleteProject`, `deleteSection`). A task's date is a day with an optional wall-clock time and duration, no time zone (`TaskDate`), and its recurrence (`Repeat`, ADR 0033, D7: `TaskRepository.complete` moves a recurring task on, reopens its sub-tasks and returns what it changed for `restore` to undo; picked through `pickRepeat`) and its reminders, minutes before its time (D5: `planReminders` plans them, `ReminderScheduler` under the person's app hands them to `ReminderGateway`, `SystemReminderGateway` on `flutter_local_notifications`, the system scheduling them but on Linux, where the running app shows them; `TodoPerson.dispose` cancels them; Android's manifest declares the boot receiver and exact alarms, the build desugars). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone. - **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, a person's space deleted with them (`deletePersonData: [deleteTodoData]`, `lib/src/todo_data.dart`). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`), `deletion-notices` (`account_deletion_notice`), and since 1 Nov 2026 `projects` (`content_key`, `project` and the sync engine's tables) and `tasks` (`section`, `task`, `label`). Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs 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 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`; from expanded windows on the pane beside the tabs instead, `TaskPane`, D2), Upcoming's week strip (`WeekStrip`, a day picked scrolling the agenda to it), search from every tab's app bar (`SearchButton` in `TabScreen`, `SearchScreen` on the tab's stack, `searchTasks` matching words folded to lower case without accents), all over `TodoCubit`, which reads everything again on any change; colours from halkyon_ui's palette (`task_texts.dart`, D9), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list. - **The icon** is Lucide's `list-todo` fading from cobalt through pacific into fern (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py todo` from the root): `assets/icon/halkyon_todo_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts the notice in the licence list.
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time. - **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.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

@ -10,6 +10,9 @@ android {
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
// flutter_local_notifications' scheduling uses java.time, which
// Android before 8 lacks (ADR 0033, D5).
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
@ -47,3 +50,7 @@ kotlin {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View file

@ -2,6 +2,12 @@
<!-- The app talks to its instance: release builds need this too (Flutter's <!-- The app talks to its instance: release builds need this too (Flutter's
template grants it to debug and profile builds only). --> template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Tasks' reminders (ADR 0033, D5): shown (POST_NOTIFICATIONS comes with
flutter_local_notifications), scheduled again after a restart, and at
the minute where the person allows exact alarms (Android 14 leaves
them off; without, Android may bring a reminder a few minutes late). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<application <application
android:label="Halkyon Todo" android:label="Halkyon Todo"
android:name="${applicationName}" android:name="${applicationName}"
@ -40,6 +46,17 @@
<data android:scheme="app.halkyon.todo" /> <data android:scheme="app.halkyon.todo" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Shows the reminders scheduled, and schedules them again after a
restart or an update (flutter_local_notifications). -->
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View file

@ -1,5 +1,6 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@ -7,6 +8,8 @@ import UIKit
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
// Reminders show while the app is open too (flutter_local_notifications).
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }

View file

@ -119,5 +119,23 @@
"repeatEnds": "Endet", "repeatEnds": "Endet",
"repeatEndsNever": "Nie", "repeatEndsNever": "Nie",
"repeatEndsOn": "An einem Tag", "repeatEndsOn": "An einem Tag",
"repeatEndsAfter": "{count, plural, =1{Nach einem Mal} other{Nach {count} Malen}}" "repeatEndsAfter": "{count, plural, =1{Nach einem Mal} other{Nach {count} Malen}}",
"remindersTitle": "Erinnerungen",
"remindersNone": "Keine Erinnerungen",
"reminderAtTime": "Zur Uhrzeit",
"reminderMinutesBefore": "{count, plural, =1{1 Minute vorher} other{{count} Minuten vorher}}",
"reminderHoursBefore": "{count, plural, =1{1 Stunde vorher} other{{count} Stunden vorher}}",
"reminderDaysBefore": "{count, plural, =1{1 Tag vorher} other{{count} Tage vorher}}",
"reminderDue": "Fällig um {time}",
"remindersChannel": "Erinnerungen an Aufgaben",
"remindersNotAllowed": "Benachrichtigungen sind für Halkyon Todo aus: Erinnerungen erscheinen, sobald du sie in den Systemeinstellungen erlaubst.",
"remindersOnThisDevice": "Erinnerungen kommen auf jedem deiner Geräte, das deine Aufgaben öffnet.",
"weekPrevious": "Vorige Woche",
"weekNext": "Folgende Woche",
"searchTitle": "Suchen",
"searchHint": "Aufgaben und Projekte suchen",
"searchEmpty": "Finde eine Aufgabe über die Wörter in ihrem Titel, ihrer Beschreibung oder ihren Labels. Die Suche läuft auf diesem Gerät: deine Instanz kann deine Aufgaben nicht lesen.",
"searchNone": "Nichts gefunden für „{query}“.",
"searchProjects": "Projekte",
"searchTasks": "Aufgaben"
} }

View file

@ -144,5 +144,28 @@
"repeatEndsNever": "Never", "repeatEndsNever": "Never",
"repeatEndsOn": "On a day", "repeatEndsOn": "On a day",
"repeatEndsAfter": "{count, plural, =1{After once} other{After {count} times}}", "repeatEndsAfter": "{count, plural, =1{After once} other{After {count} times}}",
"@repeatEndsAfter": {"placeholders": {"count": {"type": "int"}}} "@repeatEndsAfter": {"placeholders": {"count": {"type": "int"}}},
"remindersTitle": "Reminders",
"remindersNone": "No reminders",
"reminderAtTime": "At its time",
"reminderMinutesBefore": "{count, plural, =1{1 minute before} other{{count} minutes before}}",
"@reminderMinutesBefore": {"placeholders": {"count": {"type": "int"}}},
"reminderHoursBefore": "{count, plural, =1{1 hour before} other{{count} hours before}}",
"@reminderHoursBefore": {"placeholders": {"count": {"type": "int"}}},
"reminderDaysBefore": "{count, plural, =1{1 day before} other{{count} days before}}",
"@reminderDaysBefore": {"placeholders": {"count": {"type": "int"}}},
"reminderDue": "Due at {time}",
"@reminderDue": {"placeholders": {"time": {"type": "String"}}},
"remindersChannel": "Task reminders",
"remindersNotAllowed": "Notifications are off for Halkyon Todo: reminders show once you allow them in the system's settings.",
"remindersOnThisDevice": "Reminders come on each of your devices that opens your tasks.",
"weekPrevious": "Previous week",
"weekNext": "Following week",
"searchTitle": "Search",
"searchHint": "Search tasks and projects",
"searchEmpty": "Find a task by the words in its title, its description or its labels. Search runs on this device: your instance can't read your tasks.",
"searchNone": "Nothing found for “{query}”.",
"@searchNone": {"placeholders": {"query": {"type": "String"}}},
"searchProjects": "Projects",
"searchTasks": "Tasks"
} }

View file

@ -783,6 +783,114 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{count, plural, =1{After once} other{After {count} times}}'** /// **'{count, plural, =1{After once} other{After {count} times}}'**
String repeatEndsAfter(int count); String repeatEndsAfter(int count);
/// No description provided for @remindersTitle.
///
/// In en, this message translates to:
/// **'Reminders'**
String get remindersTitle;
/// No description provided for @remindersNone.
///
/// In en, this message translates to:
/// **'No reminders'**
String get remindersNone;
/// No description provided for @reminderAtTime.
///
/// In en, this message translates to:
/// **'At its time'**
String get reminderAtTime;
/// No description provided for @reminderMinutesBefore.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 minute before} other{{count} minutes before}}'**
String reminderMinutesBefore(int count);
/// No description provided for @reminderHoursBefore.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 hour before} other{{count} hours before}}'**
String reminderHoursBefore(int count);
/// No description provided for @reminderDaysBefore.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 day before} other{{count} days before}}'**
String reminderDaysBefore(int count);
/// No description provided for @reminderDue.
///
/// In en, this message translates to:
/// **'Due at {time}'**
String reminderDue(String time);
/// No description provided for @remindersChannel.
///
/// In en, this message translates to:
/// **'Task reminders'**
String get remindersChannel;
/// No description provided for @remindersNotAllowed.
///
/// In en, this message translates to:
/// **'Notifications are off for Halkyon Todo: reminders show once you allow them in the system\'s settings.'**
String get remindersNotAllowed;
/// No description provided for @remindersOnThisDevice.
///
/// In en, this message translates to:
/// **'Reminders come on each of your devices that opens your tasks.'**
String get remindersOnThisDevice;
/// No description provided for @weekPrevious.
///
/// In en, this message translates to:
/// **'Previous week'**
String get weekPrevious;
/// No description provided for @weekNext.
///
/// In en, this message translates to:
/// **'Following week'**
String get weekNext;
/// No description provided for @searchTitle.
///
/// In en, this message translates to:
/// **'Search'**
String get searchTitle;
/// No description provided for @searchHint.
///
/// In en, this message translates to:
/// **'Search tasks and projects'**
String get searchHint;
/// No description provided for @searchEmpty.
///
/// In en, this message translates to:
/// **'Find a task by the words in its title, its description or its labels. Search runs on this device: your instance can\'t read your tasks.'**
String get searchEmpty;
/// No description provided for @searchNone.
///
/// In en, this message translates to:
/// **'Nothing found for “{query}”.'**
String searchNone(String query);
/// No description provided for @searchProjects.
///
/// In en, this message translates to:
/// **'Projects'**
String get searchProjects;
/// No description provided for @searchTasks.
///
/// In en, this message translates to:
/// **'Tasks'**
String get searchTasks;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -467,4 +467,89 @@ class AppLocalizationsDe extends AppLocalizations {
); );
return '$_temp0'; return '$_temp0';
} }
@override
String get remindersTitle => 'Erinnerungen';
@override
String get remindersNone => 'Keine Erinnerungen';
@override
String get reminderAtTime => 'Zur Uhrzeit';
@override
String reminderMinutesBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Minuten vorher',
one: '1 Minute vorher',
);
return '$_temp0';
}
@override
String reminderHoursBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Stunden vorher',
one: '1 Stunde vorher',
);
return '$_temp0';
}
@override
String reminderDaysBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Tage vorher',
one: '1 Tag vorher',
);
return '$_temp0';
}
@override
String reminderDue(String time) {
return 'Fällig um $time';
}
@override
String get remindersChannel => 'Erinnerungen an Aufgaben';
@override
String get remindersNotAllowed =>
'Benachrichtigungen sind für Halkyon Todo aus: Erinnerungen erscheinen, sobald du sie in den Systemeinstellungen erlaubst.';
@override
String get remindersOnThisDevice =>
'Erinnerungen kommen auf jedem deiner Geräte, das deine Aufgaben öffnet.';
@override
String get weekPrevious => 'Vorige Woche';
@override
String get weekNext => 'Folgende Woche';
@override
String get searchTitle => 'Suchen';
@override
String get searchHint => 'Aufgaben und Projekte suchen';
@override
String get searchEmpty =>
'Finde eine Aufgabe über die Wörter in ihrem Titel, ihrer Beschreibung oder ihren Labels. Die Suche läuft auf diesem Gerät: deine Instanz kann deine Aufgaben nicht lesen.';
@override
String searchNone(String query) {
return 'Nichts gefunden für „$query“.';
}
@override
String get searchProjects => 'Projekte';
@override
String get searchTasks => 'Aufgaben';
} }

View file

@ -467,6 +467,91 @@ class AppLocalizationsEn extends AppLocalizations {
); );
return '$_temp0'; return '$_temp0';
} }
@override
String get remindersTitle => 'Reminders';
@override
String get remindersNone => 'No reminders';
@override
String get reminderAtTime => 'At its time';
@override
String reminderMinutesBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count minutes before',
one: '1 minute before',
);
return '$_temp0';
}
@override
String reminderHoursBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count hours before',
one: '1 hour before',
);
return '$_temp0';
}
@override
String reminderDaysBefore(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count days before',
one: '1 day before',
);
return '$_temp0';
}
@override
String reminderDue(String time) {
return 'Due at $time';
}
@override
String get remindersChannel => 'Task reminders';
@override
String get remindersNotAllowed =>
'Notifications are off for Halkyon Todo: reminders show once you allow them in the system\'s settings.';
@override
String get remindersOnThisDevice =>
'Reminders come on each of your devices that opens your tasks.';
@override
String get weekPrevious => 'Previous week';
@override
String get weekNext => 'Following week';
@override
String get searchTitle => 'Search';
@override
String get searchHint => 'Search tasks and projects';
@override
String get searchEmpty =>
'Find a task by the words in its title, its description or its labels. Search runs on this device: your instance can\'t read your tasks.';
@override
String searchNone(String query) {
return 'Nothing found for “$query”.';
}
@override
String get searchProjects => 'Projects';
@override
String get searchTasks => 'Tasks';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -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_frame.dart';
import 'package:halkyon_todo_flutter/src/app/todo_languages.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/app/todo_router.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_scheduler.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart'; import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -105,7 +106,8 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
], ],
child: ScreenColor( child: ScreenColor(
color: PaletteColor.strawberry.background, color: PaletteColor.strawberry.background,
child: child!, // Their tasks' reminders on this device (ADR 0033, D5).
child: ReminderScheduler(child: child!),
), ),
), ),
), ),

View file

@ -13,6 +13,8 @@ import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dar
import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.dart'; import 'package:halkyon_todo_flutter/src/feature/label/data/sync_label_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart'; import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart'; import 'package:halkyon_todo_flutter/src/feature/project/data/sync_project_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/system_reminder_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart'; import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/section/data/sync_section_gateway.dart'; import 'package:halkyon_todo_flutter/src/feature/section/data/sync_section_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart'; import 'package:halkyon_todo_flutter/src/feature/task/data/sync_task_gateway.dart';
@ -48,6 +50,10 @@ class TodoPerson(
/// Their labels, sealed and synced. /// Their labels, sealed and synced.
required final LabelRepository labels, required final LabelRepository labels,
/// The device's notifications, where their tasks' reminders go (ADR
/// 0033, D5); none in tests that don't say.
final ReminderGateway reminders = const NoReminders(),
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) implements Disposable { }) implements Disposable {
/// Deletes the project [id] with its sections and tasks; throws /// Deletes the project [id] with its sections and tasks; throws
@ -67,9 +73,12 @@ class TodoPerson(
await sections.delete([id]); await sections.delete([id]);
} }
/// Closes what was opened for the person. /// Closes what was opened for the person, and cancels their reminders:
/// a person signed out, or another signed in, isn't reminded of their
/// tasks on this device (the next person's are planned anew).
@override @override
Future<void> dispose() async { Future<void> dispose() async {
await reminders.replace(const [], channel: '');
await _onDispose?.call(); await _onDispose?.call();
} }
} }
@ -237,6 +246,7 @@ class TodoDependencies({
// One person's folder on the device at a time, as Learn keeps it. // One person's folder on the device at a time, as Learn keeps it.
await folders.openOnly(person.authUserId), await folders.openOnly(person.authUserId),
accountKeys: () => accountKeys.read(person.authUserId), accountKeys: () => accountKeys.read(person.authUserId),
reminders: device.reminders,
onClosed: closed.complete, onClosed: closed.complete,
); );
} on Object { } on Object {
@ -273,6 +283,7 @@ Future<TodoPerson> _openPersonData(
SignedIn person, SignedIn person,
Directory folder, { Directory folder, {
required Future<UnlockedAccountKeys?> Function() accountKeys, required Future<UnlockedAccountKeys?> Function() accountKeys,
required ReminderGateway reminders,
required void Function() onClosed, required void Function() onClosed,
}) async { }) async {
final db = await client.createSyncSession( final db = await client.createSyncSession(
@ -323,6 +334,7 @@ Future<TodoPerson> _openPersonData(
sections: sections, sections: sections,
tasks: tasks, tasks: tasks,
labels: labels, labels: labels,
reminders: reminders,
onDispose: () async { onDispose: () async {
try { try {
await projects.dispose(); await projects.dispose();
@ -355,6 +367,9 @@ class TodoDevice({
/// This build's version (before the `+`). /// This build's version (before the `+`).
final String appVersion = '', final String appVersion = '',
/// The system's notifications, where reminders go (ADR 0033, D5).
final ReminderGateway reminders = const NoReminders(),
}) { }) {
/// Makes them, [builtIn] being the instance the build names. /// Makes them, [builtIn] being the instance the build names.
static Future<TodoDevice> create({required Instance builtIn}) async { static Future<TodoDevice> create({required Instance builtIn}) async {
@ -365,6 +380,19 @@ class TodoDevice({
storage: InstanceStorage(support, builtIn: builtIn), storage: InstanceStorage(support, builtIn: builtIn),
appBuild: int.tryParse(package.buildNumber), appBuild: int.tryParse(package.buildNumber),
appVersion: package.version, appVersion: package.version,
reminders: await _reminders(),
); );
} }
} }
/// The system's notifications, or none where they don't start (a desktop
/// without a notification service, say): the app works on without
/// reminders.
Future<ReminderGateway> _reminders() async {
try {
return await SystemReminderGateway.create(appName: 'Halkyon Todo');
} on Object catch (error) {
log('No reminders on this device: $error', name: 'Reminders');
return const NoReminders();
}
}

View file

@ -0,0 +1,28 @@
/// A reminder as the system shows it (ADR 0033, D5): its notification's
/// `id`, when, and its words.
typedef ReminderNotice = ({int id, DateTime at, String title, String body});
/// Where the device's reminders go: the system's notifications, scheduled
/// on the device alone, since the instance can read no task (D5).
abstract interface class ReminderGateway() {
/// Makes [notices] the reminders scheduled, those scheduled before and no
/// longer among them cancelled; [channel] names their kind where the
/// system lists it (Android's notification channels).
Future<void> replace(List<ReminderNotice> notices, {required String channel});
/// Asks the person to let the app show notifications, where the system
/// asks; whether it may.
Future<bool> askPermission();
}
/// No reminders at all: a device or test without notifications.
class const NoReminders() implements ReminderGateway {
@override
Future<void> replace(
List<ReminderNotice> notices, {
required String channel,
}) async {}
@override
Future<bool> askPermission() async => false;
}

View file

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/data/reminder_gateway.dart';
import 'package:timezone/timezone.dart' as tz;
/// The system's notifications through `flutter_local_notifications` (ADR
/// 0033, D5): scheduled with the system on Android, iOS, macOS and Windows,
/// so they come while the app is closed; on Linux, which schedules none,
/// shown by the running app alone.
///
/// Each is scheduled at its instant (in UTC): a task's time is its wall
/// clock, and the app plans again whenever it runs, so a device moved to
/// another zone has its reminders moved with it once the app is opened.
class SystemReminderGateway(final FlutterLocalNotificationsPlugin _plugin)
implements ReminderGateway {
final _timers = <int, Timer>{};
/// Starts the plugin; [appName] and the Windows IDs name the app where the
/// system shows whose a notification is.
static Future<SystemReminderGateway> create({required String appName}) async {
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: InitializationSettings(
android: const AndroidInitializationSettings('@mipmap/ic_launcher'),
// Asked for when the person first adds a reminder, not at start.
iOS: const DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
macOS: const DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
linux: const LinuxInitializationSettings(defaultActionName: 'Open'),
windows: WindowsInitializationSettings(
appName: appName,
appUserModelId: 'Halkyon.Todo',
// Fixed for good: Windows knows the app's notifications by it.
guid: 'b6f1a0c2-6f0e-4d0a-9a51-3c1f2e7d8a41',
),
),
);
return SystemReminderGateway(plugin);
}
bool get _linux => defaultTargetPlatform == TargetPlatform.linux;
@override
Future<void> replace(
List<ReminderNotice> notices, {
required String channel,
}) async {
final details = NotificationDetails(
android: AndroidNotificationDetails(
'reminders',
channel,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.reminder,
// Its title is the task's: not on a locked screen.
visibility: NotificationVisibility.private,
),
iOS: const DarwinNotificationDetails(),
macOS: const DarwinNotificationDetails(),
linux: const LinuxNotificationDetails(),
);
final ids = {for (final notice in notices) notice.id};
if (_linux) {
_replaceTimers(notices, details);
return;
}
try {
for (final pending in await _plugin.pendingNotificationRequests()) {
if (!ids.contains(pending.id)) {
await _plugin.cancel(id: pending.id);
}
}
final mode = await _exact()
? AndroidScheduleMode.exactAllowWhileIdle
: AndroidScheduleMode.inexactAllowWhileIdle;
for (final notice in notices) {
await _plugin.zonedSchedule(
id: notice.id,
title: notice.title,
body: notice.body,
scheduledDate: tz.TZDateTime.from(notice.at.toUtc(), tz.UTC),
notificationDetails: details,
androidScheduleMode: mode,
);
}
} on Object catch (error) {
log('Reminders not scheduled: $error', name: 'Reminders');
}
}
/// Whether Android lets the app wake at the minute (its exact alarms,
/// which Android 14 leaves off until the person allows them in the
/// system's settings); elsewhere it always does.
Future<bool> _exact() async {
final android = _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
return android == null ||
(await android.canScheduleExactNotifications() ?? false);
}
void _replaceTimers(
List<ReminderNotice> notices,
NotificationDetails details,
) {
for (final timer in _timers.values) {
timer.cancel();
}
_timers.clear();
final now = DateTime.now();
for (final notice in notices) {
_timers[notice.id] = Timer(notice.at.difference(now), () {
_timers.remove(notice.id);
unawaited(
_plugin.show(
id: notice.id,
title: notice.title,
body: notice.body,
notificationDetails: details,
),
);
});
}
}
@override
Future<bool> askPermission() async {
try {
return switch (defaultTargetPlatform) {
TargetPlatform.android =>
await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false,
TargetPlatform.iOS =>
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
TargetPlatform.macOS =>
await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, sound: true) ??
false,
_ => true,
};
} on Object catch (error) {
log('Notifications not allowed: $error', name: 'Reminders');
return false;
}
}
}

View file

@ -0,0 +1,70 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
/// A reminder the device is to show (ADR 0033, D5): of the task [taskId]
/// titled [title], due at [time] (minutes after midnight, its wall clock),
/// [minutesBefore] it, at [at] (local time); [id] the same on every plan, so
/// that the system's notification is replaced, not doubled.
final class const PlannedReminder({
required final int id,
required final String taskId,
required final DateTime at,
required final int minutesBefore,
required final String title,
required final int time,
}) with Equatable {
@override
List<Object?> get props => [id, taskId, at, minutesBefore, title, time];
}
/// The reminders to schedule [now] (D5): every reminder of each open task
/// that has a time on its date, the nearest first, those still ahead within
/// [horizon], no more than [limit] (iOS keeps 64 pending; a recurring
/// task's later times are planned once it moves on). A task's time is its
/// wall clock, so the reminder is at that time wherever the device is now.
List<PlannedReminder> planReminders(
List<StoredTask> tasks, {
required DateTime now,
Duration horizon = const Duration(days: 30),
int limit = 48,
}) {
final end = now.add(horizon);
final plan = [
for (final StoredTask(:id, :content) in tasks)
if (content case TaskContent(
completed: false,
date: TaskDate(:final day, time: final time?),
:final reminders,
:final title,
))
for (final before in reminders.toSet())
if (DateTime(
day.year,
day.month,
day.day,
0,
time - before,
)
case final at when at.isAfter(now) && !at.isAfter(end))
PlannedReminder(
id: reminderId(id, before),
taskId: id,
at: at,
minutesBefore: before,
title: title,
time: time,
),
]..sort((a, b) => a.at.compareTo(b.at));
return plan.take(limit).toList();
}
/// The system's notification ID of the task [taskId]'s reminder [before]
/// minutes ahead: 31 bits of an FNV-1a hash, the same in every run (Dart's
/// `hashCode` of a string isn't).
int reminderId(String taskId, int before) {
var hash = 0x811c9dc5;
for (final unit in '$taskId/$before'.codeUnits) {
hash = ((hash ^ unit) * 0x01000193) & 0xffffffff;
}
return hash & 0x7fffffff;
}

View file

@ -0,0 +1,72 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The reminders offered, in minutes before a task's time.
const reminderChoices = [0, 5, 10, 15, 30, 60, 120, 1440];
/// [before] minutes ahead of a task's time, in words: "At its time", "30
/// minutes before", "1 day before".
String reminderText(AppLocalizations l10n, int before) => switch (before) {
0 => l10n.reminderAtTime,
_ when before % 1440 == 0 => l10n.reminderDaysBefore(before ~/ 1440),
_ when before % 60 == 0 => l10n.reminderHoursBefore(before ~/ 60),
_ => l10n.reminderMinutesBefore(before),
};
/// Asks which reminders a task with a time has (ADR 0033, D5, D8), from
/// [current]; null where the person changed their mind.
Future<List<int>?> pickReminders(BuildContext context, List<int> current) =>
showAdaptiveSheet<List<int>>(
context,
builder: (context) => _ReminderSheet(current: current),
);
class const _ReminderSheet({required final List<int> current})
extends StatefulWidget {
@override
State<_ReminderSheet> createState() => _ReminderSheetState();
}
class _ReminderSheetState() extends State<_ReminderSheet> {
late final Set<int> _chosen = {...widget.current};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final offered = {...reminderChoices, ...widget.current}.toList()..sort();
return SheetScaffold(
title: l10n.remindersTitle,
footer: Padding(
padding: const EdgeInsets.all(16),
child: Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: () => Navigator.of(context).pop([..._chosen]..sort()),
child: Text(l10n.save),
),
),
),
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final before in offered)
CheckboxListTile(
title: Text(reminderText(l10n, before)),
value: _chosen.contains(before),
onChanged: (on) => setState(
() => on! ? _chosen.add(before) : _chosen.remove(before),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
l10n.remindersOnThisDevice,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
);
}
}

View file

@ -0,0 +1,90 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.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/feature/reminder/model/reminder_plan.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:material_ui/material_ui.dart';
/// Keeps the device's reminders in step with the person's tasks (ADR 0033,
/// D5): planned again whenever the tasks change, here or through sync,
/// when the app comes back to the front, and every few hours as the
/// planned weeks move on; handed to the system only where the plan changed.
class const ReminderScheduler({required final Widget child, super.key})
extends StatefulWidget {
@override
State<ReminderScheduler> createState() => _ReminderSchedulerState();
}
class _ReminderSchedulerState() extends State<ReminderScheduler> {
List<PlannedReminder>? _planned;
Locale? _locale;
Timer? _timer;
AppLifecycleListener? _lifecycle;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(hours: 6), (_) => _plan());
_lifecycle = AppLifecycleListener(onResume: _plan);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Words in another language: the same plan, said anew.
final locale = Localizations.localeOf(context);
if (locale != _locale) {
_locale = locale;
_planned = null;
_plan();
}
}
void _plan() {
final state = context.read<TodoCubit>().state;
if (!state.loaded) {
return;
}
final plan = planReminders([
for (final task in state.tasks) StoredTask(task.id, task.content),
], now: clock.now());
if (listEquals(plan, _planned)) {
return;
}
_planned = plan;
final l10n = AppLocalizations.of(context);
unawaited(
PersonScope.of<TodoPerson>(context).reminders.replace([
for (final reminder in plan)
(
id: reminder.id,
at: reminder.at,
title: reminder.title,
body: l10n.reminderDue(timeText(context, reminder.time)),
),
], channel: l10n.remindersChannel),
);
}
@override
void dispose() {
_timer?.cancel();
_lifecycle?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => BlocListener<TodoCubit, TodoState>(
listenWhen: (before, after) =>
before.loaded != after.loaded || !identical(before.tasks, after.tasks),
listener: (context, state) => _plan(),
child: widget.child,
);
}

View file

@ -29,6 +29,7 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
if (content.deadline case final deadline?) 'deadline': '$deadline', if (content.deadline case final deadline?) 'deadline': '$deadline',
if (content.priority != Priority.none) 'priority': content.priority.number, if (content.priority != Priority.none) 'priority': content.priority.number,
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds, if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
if (content.reminders.isNotEmpty) 'reminders': content.reminders,
if (content.completedAt case final at?) if (content.completedAt case final at?)
'completedAt': at.toUtc().toIso8601String(), 'completedAt': at.toUtc().toIso8601String(),
if (content.completions.isNotEmpty) if (content.completions.isNotEmpty)
@ -81,6 +82,13 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
final String at => DateTime.parse(at).toLocal(), final String at => DateTime.parse(at).toLocal(),
_ => null, _ => null,
}, },
reminders: [
for (final before in json['reminders'] as List<Object?>? ?? const [])
switch (before) {
final int minutes when minutes >= 0 => minutes,
_ => throw const FormatException('reminders'),
},
],
completions: [ completions: [
for (final at in json['completions'] as List<Object?>? ?? const []) for (final at in json['completions'] as List<Object?>? ?? const [])
DateTime.parse(at! as String).toLocal(), DateTime.parse(at! as String).toLocal(),

View file

@ -139,6 +139,10 @@ final class const TaskContent({
/// Its labels' IDs. /// Its labels' IDs.
final List<String> labelIds = const [], final List<String> labelIds = const [],
/// Its reminders, each so many minutes before the time on its [date]
/// (D5); none for a date without a time.
final List<int> reminders = const [],
/// When it was done; none: not yet. /// When it was done; none: not yet.
final DateTime? completedAt, final DateTime? completedAt,
@ -160,6 +164,7 @@ final class const TaskContent({
Day? Function()? deadline, Day? Function()? deadline,
Priority? priority, Priority? priority,
List<String>? labelIds, List<String>? labelIds,
List<int>? reminders,
DateTime? Function()? completedAt, DateTime? Function()? completedAt,
List<DateTime>? completions, List<DateTime>? completions,
}) => TaskContent( }) => TaskContent(
@ -173,6 +178,7 @@ final class const TaskContent({
deadline: deadline == null ? this.deadline : deadline(), deadline: deadline == null ? this.deadline : deadline(),
priority: priority ?? this.priority, priority: priority ?? this.priority,
labelIds: labelIds ?? this.labelIds, labelIds: labelIds ?? this.labelIds,
reminders: reminders ?? this.reminders,
completedAt: completedAt == null ? this.completedAt : completedAt(), completedAt: completedAt == null ? this.completedAt : completedAt(),
completions: completions ?? this.completions, completions: completions ?? this.completions,
); );
@ -189,6 +195,7 @@ final class const TaskContent({
deadline, deadline,
priority, priority,
labelIds, labelIds,
reminders,
completedAt, completedAt,
completions, completions,
]; ];

View file

@ -0,0 +1,117 @@
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
/// The tasks of [tasks] each word of [query] is found in (ADR 0033, D2):
/// in its title, its description or one of its labels' names
/// ([labelNames], by ID), whatever the case and the accents; open ones
/// first, by their date, the nearest first, the undated after them; done
/// ones last. Nothing for a query of no words. On the device alone: the
/// instance reads no task (D5).
List<OpenTask> searchTasks(
List<OpenTask> tasks,
String query, {
Map<String, String> labelNames = const {},
}) {
final words = folded(query).split(RegExp(r'\s+'))
..removeWhere((word) => word.isEmpty);
if (words.isEmpty) {
return const [];
}
final found = [
for (final task in tasks)
if (folded(
[
task.content.title,
task.content.description,
for (final id in task.content.labelIds) ?labelNames[id],
].join('\n'),
)
case final text when words.every(text.contains))
task,
];
int rank(OpenTask task) => task.content.completed
? 2
: task.content.date == null
? 1
: 0;
return found..sort((a, b) {
final byRank = rank(a).compareTo(rank(b));
if (byRank != 0) {
return byRank;
}
final (ad, bd) = (a.content.date?.day, b.content.date?.day);
if (ad != null && bd != null && ad != bd) {
return ad.compareTo(bd);
}
return byTime(a, b);
});
}
/// [text] in lower case without its accents, so that "Café" is found by
/// "cafe" and "Straße" by "strasse".
String folded(String text) {
final lower = text.toLowerCase();
final out = StringBuffer();
for (final rune in lower.runes) {
final char = String.fromCharCode(rune);
out.write(_plain[char] ?? char);
}
return out.toString();
}
/// The Latin letters with diacritics Todo's languages and their neighbours
/// write, and what they are searched as.
const _plain = {
'à': 'a',
'á': 'a',
'â': 'a',
'ã': 'a',
'ä': 'a',
'å': 'a',
'ā': 'a',
'ą': 'a',
'æ': 'ae',
'ç': 'c',
'ć': 'c',
'č': 'c',
'ď': 'd',
'è': 'e',
'é': 'e',
'ê': 'e',
'ë': 'e',
'ē': 'e',
'ę': 'e',
'ě': 'e',
'ì': 'i',
'í': 'i',
'î': 'i',
'ï': 'i',
'ī': 'i',
'ł': 'l',
'ñ': 'n',
'ń': 'n',
'ň': 'n',
'ò': 'o',
'ó': 'o',
'ô': 'o',
'õ': 'o',
'ö': 'o',
'ø': 'o',
'ō': 'o',
'œ': 'oe',
'ř': 'r',
'ś': 's',
'š': 's',
'ß': 'ss',
'ť': 't',
'ù': 'u',
'ú': 'u',
'û': 'u',
'ü': 'u',
'ū': 'u',
'ů': 'u',
'ý': 'y',
'ÿ': 'y',
'ź': 'z',
'ż': 'z',
'ž': 'z',
};

View file

@ -0,0 +1,155 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_search.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 search over the current tab (ADR 0033, D2), in its own stack, so
/// that back returns to it and a task found opens beside the list where
/// the window is wide.
Future<void> openSearch(BuildContext context) => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (context) => const SearchScreen()));
/// Search (ADR 0033, D2): a field in the app bar, and as it is typed in
/// the projects whose name holds it, then the tasks every word is found in
/// ([searchTasks]), open ones first, each with its project; all on the
/// device, as the instance reads no task (D5).
class const SearchScreen({super.key}) extends StatefulWidget {
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState() extends State<SearchScreen> {
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
final query = _query.text;
final words = folded(query).trim();
final tasks = searchTasks(
state.tasks,
query,
labelNames: {
for (final label in state.labels)
if (label.content case final content?) label.id: content.name,
},
);
final projects = words.isEmpty
? const <({String id, String name})>[]
: [
for (final project in state.projects)
if (project.content case final content?)
if ((
id: project.id,
name: project.inbox ? l10n.shellTabInbox : content.name,
)
case final found when folded(found.name).contains(words))
found,
];
final muted = theme.colorScheme.onSurfaceVariant;
Widget heading(String text) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: theme.textTheme.titleSmall?.copyWith(color: muted),
),
);
Widget message(String text) => Padding(
padding: const EdgeInsets.all(24),
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(color: muted),
),
);
return TopGradient(
background: theme.colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: TextField(
controller: _query,
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: l10n.searchHint,
border: InputBorder.none,
),
onChanged: (_) => setState(() {}),
),
actions: [
if (query.isNotEmpty)
IconButton(
tooltip: l10n.clear,
icon: const Icon(LucideIcons.x),
onPressed: () => setState(_query.clear),
),
const SizedBox(width: 4),
],
),
body: ListView(
padding: EdgeInsets.only(
bottom: 16 + FloatingNavigationBar.coverOf(context),
),
children: [
if (words.isEmpty)
message(l10n.searchEmpty)
else if (projects.isEmpty && tasks.isEmpty)
message(l10n.searchNone(query.trim()))
else ...[
if (projects.isNotEmpty) ...[
heading(l10n.searchProjects),
for (final project in projects)
ListTile(
leading: Icon(
project.id == state.inboxId
? LucideIcons.inbox
: LucideIcons.hash,
),
title: Text(project.name),
onTap: () => context.go(
project.id == state.inboxId
? TodoPaths.inbox
: TodoPaths.project(project.id),
),
),
],
if (tasks.isNotEmpty) ...[
heading(l10n.searchTasks),
for (final task in tasks)
TaskTile(task: task, showProject: true),
],
],
],
),
),
);
}
}
/// The search button every tab's app bar carries (D2).
class const SearchButton({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => IconButton(
tooltip: AppLocalizations.of(context).searchTitle,
icon: const Icon(LucideIcons.search),
onPressed: () => openSearch(context),
);
}

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