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:
parent
aa013f5a00
commit
666e8dbcbb
35 changed files with 2140 additions and 57 deletions
|
|
@ -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.
|
||||
|
||||
- **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 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.
|
||||
- **`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.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ android {
|
|||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
// flutter_local_notifications' scheduling uses java.time, which
|
||||
// Android before 8 lacks (ADR 0033, D5).
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
|
@ -47,3 +50,7 @@ kotlin {
|
|||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
<!-- The app talks to its instance: release builds need this too (Flutter's
|
||||
template grants it to debug and profile builds only). -->
|
||||
<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
|
||||
android:label="Halkyon Todo"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -40,6 +46,17 @@
|
|||
<data android:scheme="app.halkyon.todo" />
|
||||
</intent-filter>
|
||||
</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.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
|
|
@ -7,6 +8,8 @@ import UIKit
|
|||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
// Reminders show while the app is open too (flutter_local_notifications).
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -119,5 +119,23 @@
|
|||
"repeatEnds": "Endet",
|
||||
"repeatEndsNever": "Nie",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,5 +144,28 @@
|
|||
"repeatEndsNever": "Never",
|
||||
"repeatEndsOn": "On a day",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -783,6 +783,114 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{After once} other{After {count} times}}'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -467,4 +467,89 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
);
|
||||
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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -467,6 +467,91 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
);
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -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/reminder/widget/reminder_scheduler.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';
|
||||
|
|
@ -105,7 +106,8 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
|
|||
],
|
||||
child: ScreenColor(
|
||||
color: PaletteColor.strawberry.background,
|
||||
child: child!,
|
||||
// Their tasks' reminders on this device (ADR 0033, D5).
|
||||
child: ReminderScheduler(child: child!),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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/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/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/sync_section_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.
|
||||
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,
|
||||
}) implements Disposable {
|
||||
/// Deletes the project [id] with its sections and tasks; throws
|
||||
|
|
@ -67,9 +73,12 @@ class TodoPerson(
|
|||
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
|
||||
Future<void> dispose() async {
|
||||
await reminders.replace(const [], channel: '');
|
||||
await _onDispose?.call();
|
||||
}
|
||||
}
|
||||
|
|
@ -237,6 +246,7 @@ class TodoDependencies({
|
|||
// One person's folder on the device at a time, as Learn keeps it.
|
||||
await folders.openOnly(person.authUserId),
|
||||
accountKeys: () => accountKeys.read(person.authUserId),
|
||||
reminders: device.reminders,
|
||||
onClosed: closed.complete,
|
||||
);
|
||||
} on Object {
|
||||
|
|
@ -273,6 +283,7 @@ Future<TodoPerson> _openPersonData(
|
|||
SignedIn person,
|
||||
Directory folder, {
|
||||
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
||||
required ReminderGateway reminders,
|
||||
required void Function() onClosed,
|
||||
}) async {
|
||||
final db = await client.createSyncSession(
|
||||
|
|
@ -323,6 +334,7 @@ Future<TodoPerson> _openPersonData(
|
|||
sections: sections,
|
||||
tasks: tasks,
|
||||
labels: labels,
|
||||
reminders: reminders,
|
||||
onDispose: () async {
|
||||
try {
|
||||
await projects.dispose();
|
||||
|
|
@ -355,6 +367,9 @@ class TodoDevice({
|
|||
|
||||
/// This build's version (before the `+`).
|
||||
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.
|
||||
static Future<TodoDevice> create({required Instance builtIn}) async {
|
||||
|
|
@ -365,6 +380,19 @@ class TodoDevice({
|
|||
storage: InstanceStorage(support, builtIn: builtIn),
|
||||
appBuild: int.tryParse(package.buildNumber),
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
|||
if (content.deadline case final deadline?) 'deadline': '$deadline',
|
||||
if (content.priority != Priority.none) 'priority': content.priority.number,
|
||||
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
|
||||
if (content.reminders.isNotEmpty) 'reminders': content.reminders,
|
||||
if (content.completedAt case final at?)
|
||||
'completedAt': at.toUtc().toIso8601String(),
|
||||
if (content.completions.isNotEmpty)
|
||||
|
|
@ -81,6 +82,13 @@ TaskContent? decodeTask(ByteData payload) => decodeJson(
|
|||
final String at => DateTime.parse(at).toLocal(),
|
||||
_ => 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: [
|
||||
for (final at in json['completions'] as List<Object?>? ?? const [])
|
||||
DateTime.parse(at! as String).toLocal(),
|
||||
|
|
|
|||
|
|
@ -139,6 +139,10 @@ final class const TaskContent({
|
|||
/// Its labels' IDs.
|
||||
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.
|
||||
final DateTime? completedAt,
|
||||
|
||||
|
|
@ -160,6 +164,7 @@ final class const TaskContent({
|
|||
Day? Function()? deadline,
|
||||
Priority? priority,
|
||||
List<String>? labelIds,
|
||||
List<int>? reminders,
|
||||
DateTime? Function()? completedAt,
|
||||
List<DateTime>? completions,
|
||||
}) => TaskContent(
|
||||
|
|
@ -173,6 +178,7 @@ final class const TaskContent({
|
|||
deadline: deadline == null ? this.deadline : deadline(),
|
||||
priority: priority ?? this.priority,
|
||||
labelIds: labelIds ?? this.labelIds,
|
||||
reminders: reminders ?? this.reminders,
|
||||
completedAt: completedAt == null ? this.completedAt : completedAt(),
|
||||
completions: completions ?? this.completions,
|
||||
);
|
||||
|
|
@ -189,6 +195,7 @@ final class const TaskContent({
|
|||
deadline,
|
||||
priority,
|
||||
labelIds,
|
||||
reminders,
|
||||
completedAt,
|
||||
completions,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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
Loading…
Reference in a new issue