feat(todo): the person's tasks, sections and labels, sealed and synced (ADR 0033, D6)
A table's rows are sealed for their table and row by one SealedRows, which the repositories of projects, sections, tasks and labels share, each table with its own sync gateway, a deletion blanking its rows first in one transaction. A task's payload holds its title, description, project, section, parent, place, date (a day, an optional wall-clock time and duration), deadline, priority, labels and when it was done; deleting a task deletes its sub-tasks, and a project or a section goes with its tasks. Rows that don't open show locked; a device without the account's keys writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
128bcb127b
commit
bd3710ae58
27 changed files with 1557 additions and 224 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
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), `ProjectRepository` (the Inbox under a row ID keyed by the account, made once as the person's data opens, never deleted) and `TodoPerson` opening the person's sync database, its `SyncService` and both, as Train's. 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`). 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, each saying tasks come later until they do (`lib/src/tabs/`), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app in Todo's frame (ADR 0033, D1 to D4, since 1 Nov 2026), as Train's: go_router (`buildTodoRouter`, `TodoPaths`) with a `StatefulShellRoute`, halkyon_ui's `NavigationFrame` (`TodoShell`) with the tabs Today (where it opens), Upcoming, Inbox and Browse, each saying tasks come later until they do (`lib/src/tabs/`), every screen washing down in strawberry (`ScreenColor`, `TabScreen`); Settings (`lib/src/settings/`) over the tabs from Browse's cog (its dot while sync fails), grouped in `SectionCard`s: Sync's card, the operator's card, the account (halkyon_app's `AccountTiles`, whose account on which instance, Switch account, Sign out, Delete account, which asks first, with SimpleLogin and the account's security) in Todo's words, What's new and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list), the version below (`TodoDependencies.appVersion`). What's new shows nothing yet: `main.dart` gives no `WhatsNewScope` until Todo's first release brings `assets/whats_new/`. Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
|
||||||
- **The 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.
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@ import 'package:halkyon_todo_client/halkyon_todo_client.dart'
|
||||||
show Client, UuidValue;
|
show Client, UuidValue;
|
||||||
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.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/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';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
@ -18,7 +24,8 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
|
||||||
import 'package:serverpod_flutter/serverpod_flutter.dart';
|
import 'package:serverpod_flutter/serverpod_flutter.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
/// A signed-in person's dependencies: who they are and their projects, on
|
/// A signed-in person's dependencies: who they are, their projects, sections,
|
||||||
|
/// tasks and labels, on
|
||||||
/// their own local database in their folder, which the sync engine keeps in
|
/// their own local database in their folder, which the sync engine keeps in
|
||||||
/// step with their other devices, every row sealed with their key (ADR 0033,
|
/// step with their other devices, every row sealed with their key (ADR 0033,
|
||||||
/// D5, D6). halkyon_app's `PersonScope` opens and disposes them with the
|
/// D5, D6). halkyon_app's `PersonScope` opens and disposes them with the
|
||||||
|
|
@ -32,8 +39,34 @@ class TodoPerson(
|
||||||
|
|
||||||
/// Their projects, the Inbox among them, sealed and synced.
|
/// Their projects, the Inbox among them, sealed and synced.
|
||||||
required final ProjectRepository projects,
|
required final ProjectRepository projects,
|
||||||
|
|
||||||
|
/// Their projects' sections, sealed and synced.
|
||||||
|
required final SectionRepository sections,
|
||||||
|
|
||||||
|
/// Their tasks, sealed and synced.
|
||||||
|
required final TaskRepository tasks,
|
||||||
|
|
||||||
|
/// Their labels, sealed and synced.
|
||||||
|
required final LabelRepository labels,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements Disposable {
|
}) implements Disposable {
|
||||||
|
/// Deletes the project [id] with its sections and tasks; throws
|
||||||
|
/// [InboxKept] for the Inbox, which keeps everything.
|
||||||
|
Future<void> deleteProject(String id) async {
|
||||||
|
await projects.delete(id);
|
||||||
|
await tasks.deleteIn(id);
|
||||||
|
await sections.delete([
|
||||||
|
for (final section in await sections.all())
|
||||||
|
if (section.content?.projectId == id) section.id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the section [id] of the project [projectId] with its tasks.
|
||||||
|
Future<void> deleteSection(String projectId, String id) async {
|
||||||
|
await tasks.deleteIn(projectId, sectionId: id);
|
||||||
|
await sections.delete([id]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Closes what was opened for the person.
|
/// Closes what was opened for the person.
|
||||||
@override
|
@override
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
|
|
@ -232,7 +265,7 @@ class TodoDependencies({
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens [person]'s own local database in [folder] (see [PersonFolders]), its
|
/// Opens [person]'s own local database in [folder] (see [PersonFolders]), its
|
||||||
/// sync, their key and their projects, the Inbox made where it isn't yet;
|
/// sync, their key, projects, sections, tasks and labels, the Inbox made where it isn't yet;
|
||||||
/// [onClosed] runs once all is closed again.
|
/// [onClosed] runs once all is closed again.
|
||||||
Future<TodoPerson> _openPersonData(
|
Future<TodoPerson> _openPersonData(
|
||||||
Client client,
|
Client client,
|
||||||
|
|
@ -271,6 +304,9 @@ Future<TodoPerson> _openPersonData(
|
||||||
);
|
);
|
||||||
await keys.refresh();
|
await keys.refresh();
|
||||||
final projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
|
final projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
|
||||||
|
final sections = SectionRepository(SyncSectionGateway(db, sync), keys);
|
||||||
|
final tasks = TaskRepository(SyncTaskGateway(db, sync), keys);
|
||||||
|
final labels = LabelRepository(SyncLabelGateway(db, sync), keys);
|
||||||
// The Inbox, made once by whichever device is first; nothing where this
|
// The Inbox, made once by whichever device is first; nothing where this
|
||||||
// device holds none of the account's keys.
|
// device holds none of the account's keys.
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|
@ -284,9 +320,15 @@ Future<TodoPerson> _openPersonData(
|
||||||
person,
|
person,
|
||||||
sync: sync,
|
sync: sync,
|
||||||
projects: projects,
|
projects: projects,
|
||||||
|
sections: sections,
|
||||||
|
tasks: tasks,
|
||||||
|
labels: labels,
|
||||||
onDispose: () async {
|
onDispose: () async {
|
||||||
try {
|
try {
|
||||||
await projects.dispose();
|
await projects.dispose();
|
||||||
|
await sections.dispose();
|
||||||
|
await tasks.dispose();
|
||||||
|
await labels.dispose();
|
||||||
await sync.dispose();
|
await sync.dispose();
|
||||||
await db.close();
|
await db.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// [json] as a row's payload, before sealing: UTF-8 JSON naming its
|
||||||
|
/// schema's [version] (ADR 0033, D6).
|
||||||
|
ByteData encodeJson(int version, Map<String, Object?> json) =>
|
||||||
|
ByteData.sublistView(
|
||||||
|
Uint8List.fromList(
|
||||||
|
utf8.encode(jsonEncode({'schemaVersion': version, ...json})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// What [read] makes of the JSON [payload] holds; null for a payload of a
|
||||||
|
/// schema newer than [version], one that is no JSON object, or one [read]
|
||||||
|
/// can't read (it throws), each logged under [name]: a row is never read
|
||||||
|
/// half.
|
||||||
|
T? decodeJson<T>(
|
||||||
|
ByteData payload, {
|
||||||
|
required int version,
|
||||||
|
required String name,
|
||||||
|
required T Function(Map<String, Object?> json) read,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(
|
||||||
|
utf8.decode(Uint8List.sublistView(payload)),
|
||||||
|
) as Map<String, Object?>;
|
||||||
|
final schema = json['schemaVersion']! as int;
|
||||||
|
if (schema > version) {
|
||||||
|
log('A $name of schema $schema skipped', name: name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return read(json);
|
||||||
|
} on Object catch (error) {
|
||||||
|
log("A $name's payload not read: $error", name: name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
|
||||||
|
/// A synced row as kept: its id and its envelope, sealed (ADR 0033, D5).
|
||||||
|
typedef SealedRow = ({String id, Envelope envelope});
|
||||||
|
|
||||||
|
/// Where the rows of one of Todo's synced tables are kept (`project`,
|
||||||
|
/// `section`, `task`, `label`, D6), all alike: a sealed payload each.
|
||||||
|
/// `SealedRows` seals and opens; this only keeps.
|
||||||
|
abstract interface class SealedRowGateway() {
|
||||||
|
/// Every row of the table.
|
||||||
|
Future<List<SealedRow>> rows();
|
||||||
|
|
||||||
|
Future<void> insert(SealedRow row);
|
||||||
|
|
||||||
|
/// Replaces the row [id]'s envelope (an edit); nothing for one gone.
|
||||||
|
Future<void> update(String id, Envelope envelope);
|
||||||
|
|
||||||
|
/// Deletes the rows [ids] together, each payload blanked first (ADR 0002,
|
||||||
|
/// rule 2).
|
||||||
|
Future<void> delete(Iterable<String> ids);
|
||||||
|
|
||||||
|
/// Fires when a sync brought changes from the person's other devices.
|
||||||
|
Stream<void> get remoteChanges;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Uuid;
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
|
||||||
|
/// A row as opened: its `id` and what it holds, or `content` null where it
|
||||||
|
/// doesn't open here (another person's, readable, moved from another row,
|
||||||
|
/// of a newer schema): shown as locked, never half.
|
||||||
|
typedef Opened<T> = ({String id, T? content});
|
||||||
|
|
||||||
|
/// The rows of one of Todo's synced tables (ADR 0033, D5, D6), each sealed
|
||||||
|
/// with the person's key for its table and row ([PayloadEnvelope]), so the
|
||||||
|
/// instance can neither read one nor move it to another row; a readable row
|
||||||
|
/// is one the instance made up. The repositories of projects, sections,
|
||||||
|
/// tasks and labels are built on it.
|
||||||
|
class SealedRows<T>(
|
||||||
|
final SealedRowGateway _gateway,
|
||||||
|
|
||||||
|
/// The person's key.
|
||||||
|
final TodoKeys keys, {
|
||||||
|
|
||||||
|
/// The table, which every payload is sealed for.
|
||||||
|
required final String table,
|
||||||
|
|
||||||
|
/// [T] as its payload, before sealing.
|
||||||
|
required final ByteData Function(T content) encode,
|
||||||
|
|
||||||
|
/// The [T] a payload holds; null where it can't be read.
|
||||||
|
required final T? Function(ByteData payload) decode,
|
||||||
|
}) {
|
||||||
|
StreamSubscription<void>? _remote;
|
||||||
|
|
||||||
|
/// This device's changes, and those a sync brings while anyone listens.
|
||||||
|
late final StreamController<void> _changes = StreamController.broadcast(
|
||||||
|
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
|
||||||
|
onCancel: () => _remote?.cancel(),
|
||||||
|
);
|
||||||
|
|
||||||
|
late final _envelope = PayloadEnvelope(keys.cipher);
|
||||||
|
|
||||||
|
/// Fires when the rows changed, here or on the person's other devices.
|
||||||
|
Stream<void> get changes => _changes.stream;
|
||||||
|
|
||||||
|
/// Every row, opened where it opens, by ID (so by when it was made, as
|
||||||
|
/// UUID v7 tells).
|
||||||
|
Future<List<Opened<T>>> all() async {
|
||||||
|
await keys.refresh();
|
||||||
|
final rows = await _gateway.rows()
|
||||||
|
..sort((a, b) => a.id.compareTo(b.id));
|
||||||
|
return [
|
||||||
|
for (final row in rows)
|
||||||
|
(
|
||||||
|
id: row.id,
|
||||||
|
content: switch (_open(row.envelope, row.id)) {
|
||||||
|
final payload? => decode(payload),
|
||||||
|
null => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a row [id] is kept, opened or not.
|
||||||
|
Future<bool> has(String id) async =>
|
||||||
|
(await _gateway.rows()).any((row) => row.id == id);
|
||||||
|
|
||||||
|
/// Keeps a new row, under [id] where given (a keyed one), and returns its
|
||||||
|
/// ID; throws [KeysMissing] where this device can't seal.
|
||||||
|
Future<String> insert(T content, {String? id}) async {
|
||||||
|
final rowId = id ?? const Uuid().v7();
|
||||||
|
await _gateway.insert((id: rowId, envelope: await _sealed(rowId, content)));
|
||||||
|
_changes.add(null);
|
||||||
|
return rowId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the row [id]'s content; throws [KeysMissing] where this device
|
||||||
|
/// can't seal.
|
||||||
|
Future<void> update(String id, T content) async {
|
||||||
|
await _gateway.update(id, await _sealed(id, content));
|
||||||
|
_changes.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the rows [ids] together.
|
||||||
|
Future<void> delete(Iterable<String> ids) async {
|
||||||
|
await _gateway.delete(ids);
|
||||||
|
_changes.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes what it holds open.
|
||||||
|
Future<void> dispose() => _changes.close();
|
||||||
|
|
||||||
|
Future<Envelope> _sealed(String id, T content) async => _envelope.seal(
|
||||||
|
encode(content),
|
||||||
|
keyId: await keys.keyToWrite(),
|
||||||
|
table: table,
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
|
||||||
|
ByteData? _open(Envelope envelope, String id) {
|
||||||
|
if (envelope.keyId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (_envelope.open(envelope, table: table, id: id)) {
|
||||||
|
Readable(:final payload) => payload,
|
||||||
|
Locked() => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
||||||
|
|
||||||
|
/// The schema of a label's payload this app writes and reads.
|
||||||
|
const labelSchemaVersion = 1;
|
||||||
|
|
||||||
|
/// [content] as its row's payload, before sealing.
|
||||||
|
ByteData encodeLabel(LabelContent content) => encodeJson(labelSchemaVersion, {
|
||||||
|
'name': content.name,
|
||||||
|
'color': ?content.color?.name,
|
||||||
|
'position': content.position,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The label [payload] holds, opened; null for one it can't read.
|
||||||
|
LabelContent? decodeLabel(ByteData payload) => decodeJson(
|
||||||
|
payload,
|
||||||
|
version: labelSchemaVersion,
|
||||||
|
name: 'Label',
|
||||||
|
read: (json) => LabelContent(
|
||||||
|
name: json['name']! as String,
|
||||||
|
color: paletteColorNamed(json['color'] as String?),
|
||||||
|
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/label/data/label_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||||
|
|
||||||
|
/// The person's labels (ADR 0033, D6), each sealed with their key for its
|
||||||
|
/// row (D5, [SealedRows]). A task naming a label gone shows without it.
|
||||||
|
class LabelRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
||||||
|
late final _rows = SealedRows<LabelContent>(
|
||||||
|
_gateway,
|
||||||
|
_keys,
|
||||||
|
table: 'label',
|
||||||
|
encode: encodeLabel,
|
||||||
|
decode: decodeLabel,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Fires when the labels changed, here or on another device.
|
||||||
|
Stream<void> get changes => _rows.changes;
|
||||||
|
|
||||||
|
/// Every label, by when it was made.
|
||||||
|
Future<List<StoredLabel>> all() async => [
|
||||||
|
for (final (:id, :content) in await _rows.all()) StoredLabel(id, content),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Keeps a new label and returns its ID; throws [KeysMissing] where this
|
||||||
|
/// device can't seal.
|
||||||
|
Future<String> add(LabelContent content) => _rows.insert(content);
|
||||||
|
|
||||||
|
Future<void> update(String id, LabelContent content) =>
|
||||||
|
_rows.update(id, content);
|
||||||
|
|
||||||
|
Future<void> delete(String id) => _rows.delete([id]);
|
||||||
|
|
||||||
|
/// Closes what it holds open.
|
||||||
|
Future<void> dispose() => _rows.dispose();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
|
/// The person's labels on their local database ([SealedRowGateway]), which the
|
||||||
|
/// sync engine keeps in step with their other devices.
|
||||||
|
class SyncLabelGateway(
|
||||||
|
final OfflineSyncDatabaseSession _db,
|
||||||
|
final SyncService _sync,
|
||||||
|
) implements SealedRowGateway {
|
||||||
|
@override
|
||||||
|
Future<List<SealedRow>> rows() async => [
|
||||||
|
for (final row in await protocol.Label.db.find(_db, orderBy: (t) => t.id))
|
||||||
|
(
|
||||||
|
id: row.id!.uuid,
|
||||||
|
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insert(SealedRow row) async {
|
||||||
|
await protocol.Label.db.insertRow(
|
||||||
|
_db,
|
||||||
|
protocol.Label(
|
||||||
|
id: protocol.UuidValue.withValidation(row.id),
|
||||||
|
keyId: row.envelope.keyId,
|
||||||
|
nonce: row.envelope.nonce,
|
||||||
|
payload: row.envelope.payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> update(String id, Envelope envelope) async {
|
||||||
|
if (await protocol.Label.db.findById(
|
||||||
|
_db,
|
||||||
|
protocol.UuidValue.withValidation(id),
|
||||||
|
)
|
||||||
|
case final row?) {
|
||||||
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
|
await protocol.Label.db.updateRow(
|
||||||
|
_db,
|
||||||
|
row.copyWith(
|
||||||
|
keyId: envelope.keyId,
|
||||||
|
nonce: envelope.nonce,
|
||||||
|
payload: envelope.payload,
|
||||||
|
),
|
||||||
|
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(Iterable<String> ids) async {
|
||||||
|
final uuids = <protocol.UuidValue>{
|
||||||
|
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||||
|
};
|
||||||
|
if (uuids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.db.transaction((transaction) async {
|
||||||
|
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||||
|
// they last were, until the space goes.
|
||||||
|
for (final row in await protocol.Label.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.id.inSet(uuids),
|
||||||
|
transaction: transaction,
|
||||||
|
)) {
|
||||||
|
await protocol.Label.db.updateRow(
|
||||||
|
_db,
|
||||||
|
protocol.Label(
|
||||||
|
id: row.id,
|
||||||
|
spaceId: row.spaceId,
|
||||||
|
payload: ByteData(0),
|
||||||
|
),
|
||||||
|
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await protocol.Label.db.deleteWhere(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.id.inSet(uuids),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get remoteChanges => _sync.merges;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
|
||||||
|
/// What a label is (ADR 0033, D6): its [name], its [color] from halkyon_ui's
|
||||||
|
/// palette (D9) and its [position] among the others.
|
||||||
|
final class const LabelContent({
|
||||||
|
required final String name,
|
||||||
|
final PaletteColor? color,
|
||||||
|
final double position = 0,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [name, color, position];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A label as kept: its [id], and [content] null where it doesn't open.
|
||||||
|
final class const StoredLabel(final String id, final LabelContent? content)
|
||||||
|
with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, content];
|
||||||
|
}
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
|
||||||
|
|
||||||
/// A `project` row as kept: its id and its envelope, sealed (ADR 0033, D5).
|
|
||||||
typedef ProjectRow = ({String id, Envelope envelope});
|
|
||||||
|
|
||||||
/// Where the person's projects are kept: their synced `project` rows
|
|
||||||
/// (D6). The repository seals and opens; this only keeps.
|
|
||||||
abstract interface class ProjectGateway() {
|
|
||||||
Future<List<ProjectRow>> projects();
|
|
||||||
|
|
||||||
Future<void> insert(ProjectRow project);
|
|
||||||
|
|
||||||
/// Replaces the project [id]'s envelope (an edit); nothing for one gone.
|
|
||||||
Future<void> update(String id, Envelope envelope);
|
|
||||||
|
|
||||||
/// Deletes the project [id], its payload blanked first (ADR 0002, rule 2).
|
|
||||||
Future<void> delete(String id);
|
|
||||||
|
|
||||||
/// Fires when a sync brought changes from the person's other devices.
|
|
||||||
Stream<void> get remoteChanges;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
|
||||||
|
|
@ -10,39 +9,26 @@ const projectSchemaVersion = 1;
|
||||||
|
|
||||||
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
|
/// [content] as its row's payload, before sealing: UTF-8 JSON, the colour by
|
||||||
/// its palette name.
|
/// its palette name.
|
||||||
ByteData encodeProject(ProjectContent content) => ByteData.sublistView(
|
ByteData encodeProject(ProjectContent content) =>
|
||||||
Uint8List.fromList(
|
encodeJson(projectSchemaVersion, {
|
||||||
utf8.encode(
|
'name': content.name,
|
||||||
jsonEncode({
|
'color': ?content.color?.name,
|
||||||
'schemaVersion': projectSchemaVersion,
|
'position': content.position,
|
||||||
'name': content.name,
|
});
|
||||||
'color': ?content.color?.name,
|
|
||||||
'position': content.position,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/// The project [payload] holds, opened; null for one of a newer schema or one
|
/// The project [payload] holds, opened; null for one of a newer schema or one
|
||||||
/// it can't read. A colour it doesn't know is none.
|
/// it can't read. A colour it doesn't know is none.
|
||||||
ProjectContent? decodeProject(ByteData payload) {
|
ProjectContent? decodeProject(ByteData payload) => decodeJson(
|
||||||
try {
|
payload,
|
||||||
final json = jsonDecode(
|
version: projectSchemaVersion,
|
||||||
utf8.decode(Uint8List.sublistView(payload)),
|
name: 'Project',
|
||||||
) as Map<String, Object?>;
|
read: (json) => ProjectContent(
|
||||||
final version = json['schemaVersion']! as int;
|
name: json['name']! as String,
|
||||||
if (version > projectSchemaVersion) {
|
color: paletteColorNamed(json['color'] as String?),
|
||||||
log('A project of schema $version skipped', name: 'Project');
|
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||||
return null;
|
),
|
||||||
}
|
);
|
||||||
final color = json['color'] as String?;
|
|
||||||
return ProjectContent(
|
/// The palette's colour named [name]; none for a name it doesn't know.
|
||||||
name: json['name']! as String,
|
PaletteColor? paletteColorNamed(String? name) =>
|
||||||
color: PaletteColor.values.where((c) => c.name == color).firstOrNull,
|
PaletteColor.values.where((color) => color.name == name).firstOrNull;
|
||||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
|
||||||
);
|
|
||||||
} on Object catch (error) {
|
|
||||||
log("A project's payload not read: $error", name: 'Project');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
|
||||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Uuid;
|
|
||||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||||
|
|
@ -16,102 +12,70 @@ final class InboxKept() implements Exception {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The person's projects (ADR 0033, D6), each sealed with their key for its
|
/// The person's projects (ADR 0033, D6), each sealed with their key for its
|
||||||
/// row (D5); a readable row is one the instance made up, and shows locked.
|
/// row (D5, [SealedRows]).
|
||||||
///
|
///
|
||||||
/// The Inbox is the row under an ID keyed by the account
|
/// The Inbox is the row under an ID keyed by the account
|
||||||
/// ([TodoKeys.rowId], Train's ADR 0030, D57): every device of the person's
|
/// ([TodoKeys.rowId], Train's ADR 0030, D57): every device of the person's
|
||||||
/// makes the same one, so two that make it offline write one row, and the
|
/// makes the same one, so two that make it offline write one row, and the
|
||||||
/// instance can tell neither which row it is nor whose.
|
/// instance can tell neither which row it is nor whose.
|
||||||
class ProjectRepository(final ProjectGateway _gateway, final TodoKeys _keys) {
|
class ProjectRepository(
|
||||||
StreamSubscription<void>? _remote;
|
final SealedRowGateway _gateway,
|
||||||
|
final TodoKeys _keys,
|
||||||
/// This device's changes, and those a sync brings while anyone listens.
|
) {
|
||||||
late final StreamController<void> _changes = StreamController.broadcast(
|
late final _rows = SealedRows<ProjectContent>(
|
||||||
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
|
_gateway,
|
||||||
onCancel: () => _remote?.cancel(),
|
_keys,
|
||||||
|
table: 'project',
|
||||||
|
encode: encodeProject,
|
||||||
|
decode: decodeProject,
|
||||||
);
|
);
|
||||||
|
|
||||||
late final _envelope = PayloadEnvelope(_keys.cipher);
|
|
||||||
|
|
||||||
/// Fires when the projects changed, here or on the person's other devices.
|
/// Fires when the projects changed, here or on the person's other devices.
|
||||||
Stream<void> get changes => _changes.stream;
|
Stream<void> get changes => _rows.changes;
|
||||||
|
|
||||||
/// The Inbox's row ID; throws [KeysMissing] without the account's keys.
|
/// The Inbox's row ID; throws [KeysMissing] without the account's keys.
|
||||||
String get inboxId => _keys.rowId('todo/project/inbox');
|
String get inboxId => _rows.keys.rowId('todo/project/inbox');
|
||||||
|
|
||||||
/// Every project, the Inbox first, then by their position, ties by when
|
/// Every project, the Inbox first, then by their position, ties by when
|
||||||
/// they were made; one that doesn't open here with its content null.
|
/// they were made; one that doesn't open here with its content null.
|
||||||
Future<List<StoredProject>> all() async {
|
Future<List<StoredProject>> all() async {
|
||||||
await _keys.refresh();
|
final rows = await _rows.all();
|
||||||
final inbox = _keys.unlocked ? inboxId : null;
|
final inbox = _rows.keys.unlocked ? inboxId : null;
|
||||||
final projects = [
|
return [
|
||||||
for (final row in await _gateway.projects())
|
for (final (:id, :content) in rows)
|
||||||
StoredProject(row.id, switch (_open(row.envelope, row.id)) {
|
StoredProject(id, content, inbox: id == inbox),
|
||||||
final payload? => decodeProject(payload),
|
]..sort(_order);
|
||||||
null => null,
|
|
||||||
}, inbox: row.id == inbox),
|
|
||||||
];
|
|
||||||
return projects..sort(_order);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Makes the Inbox where it isn't yet and returns its ID; throws
|
/// Makes the Inbox where it isn't yet and returns its ID; throws
|
||||||
/// [KeysMissing] where this device can't seal.
|
/// [KeysMissing] where this device can't seal.
|
||||||
Future<String> ensureInbox() async {
|
Future<String> ensureInbox() async {
|
||||||
await _keys.refresh();
|
await _rows.keys.refresh();
|
||||||
final id = inboxId;
|
final id = inboxId;
|
||||||
if (!(await _gateway.projects()).any((row) => row.id == id)) {
|
if (!await _rows.has(id)) {
|
||||||
await _gateway.insert((
|
await _rows.insert(ProjectContent.inbox, id: id);
|
||||||
id: id,
|
|
||||||
envelope: await _sealed(id, ProjectContent.inbox),
|
|
||||||
));
|
|
||||||
_changes.add(null);
|
|
||||||
}
|
}
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps a new project and returns its ID; throws [KeysMissing] where this
|
/// Keeps a new project and returns its ID; throws [KeysMissing] where this
|
||||||
/// device can't seal.
|
/// device can't seal.
|
||||||
Future<String> save(ProjectContent content) async {
|
Future<String> save(ProjectContent content) => _rows.insert(content);
|
||||||
final id = const Uuid().v7();
|
|
||||||
await _gateway.insert((id: id, envelope: await _sealed(id, content)));
|
|
||||||
_changes.add(null);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> update(String id, ProjectContent content) async {
|
Future<void> update(String id, ProjectContent content) =>
|
||||||
await _gateway.update(id, await _sealed(id, content));
|
_rows.update(id, content);
|
||||||
_changes.add(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deletes the project [id]; throws [InboxKept] for the Inbox.
|
/// Deletes the project [id]; throws [InboxKept] for the Inbox. Its tasks
|
||||||
|
/// and sections go with it (the caller's, `TodoData`).
|
||||||
Future<void> delete(String id) async {
|
Future<void> delete(String id) async {
|
||||||
if (_keys.unlocked && id == inboxId) {
|
if (_rows.keys.unlocked && id == inboxId) {
|
||||||
throw InboxKept();
|
throw InboxKept();
|
||||||
}
|
}
|
||||||
await _gateway.delete(id);
|
await _rows.delete([id]);
|
||||||
_changes.add(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Closes what it holds open.
|
/// Closes what it holds open.
|
||||||
Future<void> dispose() => _changes.close();
|
Future<void> dispose() => _rows.dispose();
|
||||||
|
|
||||||
Future<Envelope> _sealed(String id, ProjectContent content) async =>
|
|
||||||
_envelope.seal(
|
|
||||||
encodeProject(content),
|
|
||||||
keyId: await _keys.keyToWrite(),
|
|
||||||
table: 'project',
|
|
||||||
id: id,
|
|
||||||
);
|
|
||||||
|
|
||||||
ByteData? _open(Envelope envelope, String id) {
|
|
||||||
if (envelope.keyId == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return switch (_envelope.open(envelope, table: 'project', id: id)) {
|
|
||||||
Readable(:final payload) => payload,
|
|
||||||
Locked() => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static int _order(StoredProject a, StoredProject b) {
|
static int _order(StoredProject a, StoredProject b) {
|
||||||
if (a.inbox != b.inbox) {
|
if (a.inbox != b.inbox) {
|
||||||
|
|
|
||||||
|
|
@ -2,40 +2,33 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
/// [ProjectGateway] on the person's local database, which the sync engine
|
/// The person's projects on their local database ([SealedRowGateway]), which the
|
||||||
/// keeps in step with their other devices.
|
/// sync engine keeps in step with their other devices.
|
||||||
class SyncProjectGateway(
|
class SyncProjectGateway(
|
||||||
final OfflineSyncDatabaseSession _db,
|
final OfflineSyncDatabaseSession _db,
|
||||||
final SyncService _sync,
|
final SyncService _sync,
|
||||||
) implements ProjectGateway {
|
) implements SealedRowGateway {
|
||||||
@override
|
@override
|
||||||
Future<List<ProjectRow>> projects() async => [
|
Future<List<SealedRow>> rows() async => [
|
||||||
for (final project in await protocol.Project.db.find(
|
for (final row in await protocol.Project.db.find(_db, orderBy: (t) => t.id))
|
||||||
_db,
|
|
||||||
orderBy: (t) => t.id,
|
|
||||||
))
|
|
||||||
(
|
(
|
||||||
id: project.id!.uuid,
|
id: row.id!.uuid,
|
||||||
envelope: (
|
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
keyId: project.keyId,
|
|
||||||
nonce: project.nonce,
|
|
||||||
payload: project.payload,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> insert(ProjectRow project) async {
|
Future<void> insert(SealedRow row) async {
|
||||||
await protocol.Project.db.insertRow(
|
await protocol.Project.db.insertRow(
|
||||||
_db,
|
_db,
|
||||||
protocol.Project(
|
protocol.Project(
|
||||||
id: protocol.UuidValue.withValidation(project.id),
|
id: protocol.UuidValue.withValidation(row.id),
|
||||||
keyId: project.envelope.keyId,
|
keyId: row.envelope.keyId,
|
||||||
nonce: project.envelope.nonce,
|
nonce: row.envelope.nonce,
|
||||||
payload: project.envelope.payload,
|
payload: row.envelope.payload,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_sync.requestSync();
|
_sync.requestSync();
|
||||||
|
|
@ -47,11 +40,11 @@ class SyncProjectGateway(
|
||||||
_db,
|
_db,
|
||||||
protocol.UuidValue.withValidation(id),
|
protocol.UuidValue.withValidation(id),
|
||||||
)
|
)
|
||||||
case final project?) {
|
case final row?) {
|
||||||
// Only the named columns change (ADR 0001, rule 1).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
await protocol.Project.db.updateRow(
|
await protocol.Project.db.updateRow(
|
||||||
_db,
|
_db,
|
||||||
project.copyWith(
|
row.copyWith(
|
||||||
keyId: envelope.keyId,
|
keyId: envelope.keyId,
|
||||||
nonce: envelope.nonce,
|
nonce: envelope.nonce,
|
||||||
payload: envelope.payload,
|
payload: envelope.payload,
|
||||||
|
|
@ -63,22 +56,26 @@ class SyncProjectGateway(
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> delete(String id) async {
|
Future<void> delete(Iterable<String> ids) async {
|
||||||
final uuid = protocol.UuidValue.withValidation(id);
|
final uuids = <protocol.UuidValue>{
|
||||||
|
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||||
|
};
|
||||||
|
if (uuids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await _db.db.transaction((transaction) async {
|
await _db.db.transaction((transaction) async {
|
||||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||||
// they last were, until the space goes.
|
// they last were, until the space goes.
|
||||||
if (await protocol.Project.db.findById(
|
for (final row in await protocol.Project.db.find(
|
||||||
_db,
|
_db,
|
||||||
uuid,
|
where: (t) => t.id.inSet(uuids),
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
)
|
)) {
|
||||||
case final project?) {
|
|
||||||
await protocol.Project.db.updateRow(
|
await protocol.Project.db.updateRow(
|
||||||
_db,
|
_db,
|
||||||
protocol.Project(
|
protocol.Project(
|
||||||
id: project.id,
|
id: row.id,
|
||||||
spaceId: project.spaceId,
|
spaceId: row.spaceId,
|
||||||
payload: ByteData(0),
|
payload: ByteData(0),
|
||||||
),
|
),
|
||||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||||
|
|
@ -87,7 +84,7 @@ class SyncProjectGateway(
|
||||||
}
|
}
|
||||||
await protocol.Project.db.deleteWhere(
|
await protocol.Project.db.deleteWhere(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.equals(uuid),
|
where: (t) => t.id.inSet(uuids),
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||||
|
|
||||||
|
/// The schema of a section's payload this app writes and reads.
|
||||||
|
const sectionSchemaVersion = 1;
|
||||||
|
|
||||||
|
/// [content] as its row's payload, before sealing.
|
||||||
|
ByteData encodeSection(SectionContent content) =>
|
||||||
|
encodeJson(sectionSchemaVersion, {
|
||||||
|
'name': content.name,
|
||||||
|
'projectId': content.projectId,
|
||||||
|
if (content.description.isNotEmpty) 'description': content.description,
|
||||||
|
'position': content.position,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The section [payload] holds, opened; null for one it can't read.
|
||||||
|
SectionContent? decodeSection(ByteData payload) => decodeJson(
|
||||||
|
payload,
|
||||||
|
version: sectionSchemaVersion,
|
||||||
|
name: 'Section',
|
||||||
|
read: (json) => SectionContent(
|
||||||
|
name: json['name']! as String,
|
||||||
|
projectId: json['projectId']! as String,
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||||
|
|
||||||
|
/// The sections of the person's projects (ADR 0033, D6), each sealed with
|
||||||
|
/// their key for its row (D5, [SealedRows]).
|
||||||
|
class SectionRepository(
|
||||||
|
final SealedRowGateway _gateway,
|
||||||
|
final TodoKeys _keys,
|
||||||
|
) {
|
||||||
|
late final _rows = SealedRows<SectionContent>(
|
||||||
|
_gateway,
|
||||||
|
_keys,
|
||||||
|
table: 'section',
|
||||||
|
encode: encodeSection,
|
||||||
|
decode: decodeSection,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Fires when the sections changed, here or on another device.
|
||||||
|
Stream<void> get changes => _rows.changes;
|
||||||
|
|
||||||
|
/// Every section, by when it was made.
|
||||||
|
Future<List<StoredSection>> all() async => [
|
||||||
|
for (final (:id, :content) in await _rows.all()) StoredSection(id, content),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Keeps a new section and returns its ID; throws [KeysMissing] where
|
||||||
|
/// this device can't seal.
|
||||||
|
Future<String> add(SectionContent content) => _rows.insert(content);
|
||||||
|
|
||||||
|
Future<void> update(String id, SectionContent content) =>
|
||||||
|
_rows.update(id, content);
|
||||||
|
|
||||||
|
/// Deletes the sections [ids] together (their tasks are the caller's).
|
||||||
|
Future<void> delete(Iterable<String> ids) => _rows.delete(ids);
|
||||||
|
|
||||||
|
/// Closes what it holds open.
|
||||||
|
Future<void> dispose() => _rows.dispose();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
|
/// The person's sections on their local database ([SealedRowGateway]), which the
|
||||||
|
/// sync engine keeps in step with their other devices.
|
||||||
|
class SyncSectionGateway(
|
||||||
|
final OfflineSyncDatabaseSession _db,
|
||||||
|
final SyncService _sync,
|
||||||
|
) implements SealedRowGateway {
|
||||||
|
@override
|
||||||
|
Future<List<SealedRow>> rows() async => [
|
||||||
|
for (final row in await protocol.Section.db.find(_db, orderBy: (t) => t.id))
|
||||||
|
(
|
||||||
|
id: row.id!.uuid,
|
||||||
|
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insert(SealedRow row) async {
|
||||||
|
await protocol.Section.db.insertRow(
|
||||||
|
_db,
|
||||||
|
protocol.Section(
|
||||||
|
id: protocol.UuidValue.withValidation(row.id),
|
||||||
|
keyId: row.envelope.keyId,
|
||||||
|
nonce: row.envelope.nonce,
|
||||||
|
payload: row.envelope.payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> update(String id, Envelope envelope) async {
|
||||||
|
if (await protocol.Section.db.findById(
|
||||||
|
_db,
|
||||||
|
protocol.UuidValue.withValidation(id),
|
||||||
|
)
|
||||||
|
case final row?) {
|
||||||
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
|
await protocol.Section.db.updateRow(
|
||||||
|
_db,
|
||||||
|
row.copyWith(
|
||||||
|
keyId: envelope.keyId,
|
||||||
|
nonce: envelope.nonce,
|
||||||
|
payload: envelope.payload,
|
||||||
|
),
|
||||||
|
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(Iterable<String> ids) async {
|
||||||
|
final uuids = <protocol.UuidValue>{
|
||||||
|
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||||
|
};
|
||||||
|
if (uuids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.db.transaction((transaction) async {
|
||||||
|
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||||
|
// they last were, until the space goes.
|
||||||
|
for (final row in await protocol.Section.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.id.inSet(uuids),
|
||||||
|
transaction: transaction,
|
||||||
|
)) {
|
||||||
|
await protocol.Section.db.updateRow(
|
||||||
|
_db,
|
||||||
|
protocol.Section(
|
||||||
|
id: row.id,
|
||||||
|
spaceId: row.spaceId,
|
||||||
|
payload: ByteData(0),
|
||||||
|
),
|
||||||
|
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await protocol.Section.db.deleteWhere(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.id.inSet(uuids),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get remoteChanges => _sync.merges;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// What a section of a project is (ADR 0033, D6): its [name], a
|
||||||
|
/// [description], the project it is in and its [position] there.
|
||||||
|
final class const SectionContent({
|
||||||
|
required final String name,
|
||||||
|
required final String projectId,
|
||||||
|
final String description = '',
|
||||||
|
final double position = 0,
|
||||||
|
}) with Equatable {
|
||||||
|
SectionContent copyWith({
|
||||||
|
String? name,
|
||||||
|
String? projectId,
|
||||||
|
String? description,
|
||||||
|
double? position,
|
||||||
|
}) => SectionContent(
|
||||||
|
name: name ?? this.name,
|
||||||
|
projectId: projectId ?? this.projectId,
|
||||||
|
description: description ?? this.description,
|
||||||
|
position: position ?? this.position,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [name, projectId, description, position];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section as kept: its [id], and [content] null where it doesn't open.
|
||||||
|
final class const StoredSection(final String id, final SectionContent? content)
|
||||||
|
with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, content];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
|
/// The person's tasks on their local database ([SealedRowGateway]), which the
|
||||||
|
/// sync engine keeps in step with their other devices.
|
||||||
|
class SyncTaskGateway(
|
||||||
|
final OfflineSyncDatabaseSession _db,
|
||||||
|
final SyncService _sync,
|
||||||
|
) implements SealedRowGateway {
|
||||||
|
@override
|
||||||
|
Future<List<SealedRow>> rows() async => [
|
||||||
|
for (final row in await protocol.Task.db.find(_db, orderBy: (t) => t.id))
|
||||||
|
(
|
||||||
|
id: row.id!.uuid,
|
||||||
|
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> insert(SealedRow row) async {
|
||||||
|
await protocol.Task.db.insertRow(
|
||||||
|
_db,
|
||||||
|
protocol.Task(
|
||||||
|
id: protocol.UuidValue.withValidation(row.id),
|
||||||
|
keyId: row.envelope.keyId,
|
||||||
|
nonce: row.envelope.nonce,
|
||||||
|
payload: row.envelope.payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> update(String id, Envelope envelope) async {
|
||||||
|
if (await protocol.Task.db.findById(
|
||||||
|
_db,
|
||||||
|
protocol.UuidValue.withValidation(id),
|
||||||
|
)
|
||||||
|
case final row?) {
|
||||||
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
|
await protocol.Task.db.updateRow(
|
||||||
|
_db,
|
||||||
|
row.copyWith(
|
||||||
|
keyId: envelope.keyId,
|
||||||
|
nonce: envelope.nonce,
|
||||||
|
payload: envelope.payload,
|
||||||
|
),
|
||||||
|
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||||
|
);
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> delete(Iterable<String> ids) async {
|
||||||
|
final uuids = <protocol.UuidValue>{
|
||||||
|
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||||
|
};
|
||||||
|
if (uuids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _db.db.transaction((transaction) async {
|
||||||
|
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||||
|
// they last were, until the space goes.
|
||||||
|
for (final row in await protocol.Task.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.id.inSet(uuids),
|
||||||
|
transaction: transaction,
|
||||||
|
)) {
|
||||||
|
await protocol.Task.db.updateRow(
|
||||||
|
_db,
|
||||||
|
protocol.Task(id: row.id, spaceId: row.spaceId, payload: ByteData(0)),
|
||||||
|
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await protocol.Task.db.deleteWhere(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.id.inSet(uuids),
|
||||||
|
transaction: transaction,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
_sync.requestSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<void> get remoteChanges => _sync.merges;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||||
|
|
||||||
|
/// The schema of a task's payload this app writes and reads.
|
||||||
|
const taskSchemaVersion = 1;
|
||||||
|
|
||||||
|
/// [content] as its row's payload, before sealing: UTF-8 JSON, days as
|
||||||
|
/// `YYYY-MM-DD`, a time as minutes after midnight, a duration in minutes,
|
||||||
|
/// the priority as its number, when it was done in UTC.
|
||||||
|
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
||||||
|
'title': content.title,
|
||||||
|
'projectId': content.projectId,
|
||||||
|
if (content.description.isNotEmpty) 'description': content.description,
|
||||||
|
'sectionId': ?content.sectionId,
|
||||||
|
'parentId': ?content.parentId,
|
||||||
|
'position': content.position,
|
||||||
|
if (content.date case final date?)
|
||||||
|
'date': {
|
||||||
|
'day': '${date.day}',
|
||||||
|
'time': ?date.time,
|
||||||
|
'minutes': ?date.duration?.inMinutes,
|
||||||
|
},
|
||||||
|
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.completedAt case final at?)
|
||||||
|
'completedAt': at.toUtc().toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The task [payload] holds, opened; null for one of a newer schema or one
|
||||||
|
/// it can't read: a task is never read half, so one whose date can't be
|
||||||
|
/// read isn't read at all.
|
||||||
|
TaskContent? decodeTask(ByteData payload) => decodeJson(
|
||||||
|
payload,
|
||||||
|
version: taskSchemaVersion,
|
||||||
|
name: 'Task',
|
||||||
|
read: (json) => TaskContent(
|
||||||
|
title: json['title']! as String,
|
||||||
|
projectId: json['projectId']! as String,
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
sectionId: json['sectionId'] as String?,
|
||||||
|
parentId: json['parentId'] as String?,
|
||||||
|
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||||
|
date: switch (json['date']) {
|
||||||
|
final Map<String, Object?> date => _date(date),
|
||||||
|
null => null,
|
||||||
|
_ => throw const FormatException('date'),
|
||||||
|
},
|
||||||
|
deadline: switch (json['deadline']) {
|
||||||
|
final String day => Day.tryParse(day) ?? (throw const FormatException()),
|
||||||
|
null => null,
|
||||||
|
_ => throw const FormatException('deadline'),
|
||||||
|
},
|
||||||
|
priority: Priority.ofNumber(json['priority'] as int?),
|
||||||
|
labelIds: [
|
||||||
|
for (final id in json['labelIds'] as List<Object?>? ?? const [])
|
||||||
|
id! as String,
|
||||||
|
],
|
||||||
|
completedAt: switch (json['completedAt']) {
|
||||||
|
final String at => DateTime.parse(at).toLocal(),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
TaskDate _date(Map<String, Object?> json) {
|
||||||
|
final day = Day.tryParse(json['day']! as String);
|
||||||
|
final time = json['time'] as int?;
|
||||||
|
if (day == null || (time != null && (time < 0 || time >= 24 * 60))) {
|
||||||
|
throw const FormatException('date');
|
||||||
|
}
|
||||||
|
return TaskDate(
|
||||||
|
day,
|
||||||
|
time: time,
|
||||||
|
duration: switch (json['minutes']) {
|
||||||
|
final int minutes => Duration(minutes: minutes),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
|
||||||
|
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||||
|
|
||||||
|
/// The person's tasks (ADR 0033, D6), each sealed with their key for its row
|
||||||
|
/// (D5, [SealedRows]): which project a task is in, its date and whether it
|
||||||
|
/// is done are in its payload, so the instance learns none of it.
|
||||||
|
class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
||||||
|
late final _rows = SealedRows<TaskContent>(
|
||||||
|
_gateway,
|
||||||
|
_keys,
|
||||||
|
table: 'task',
|
||||||
|
encode: encodeTask,
|
||||||
|
decode: decodeTask,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Fires when the tasks changed, here or on the person's other devices.
|
||||||
|
Stream<void> get changes => _rows.changes;
|
||||||
|
|
||||||
|
/// Every task, by when it was made; one that doesn't open here with its
|
||||||
|
/// content null.
|
||||||
|
Future<List<StoredTask>> all() async => [
|
||||||
|
for (final (:id, :content) in await _rows.all()) StoredTask(id, content),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Keeps a new task and returns its ID; throws [KeysMissing] where this
|
||||||
|
/// device can't seal.
|
||||||
|
Future<String> add(TaskContent content) => _rows.insert(content);
|
||||||
|
|
||||||
|
Future<void> update(String id, TaskContent content) =>
|
||||||
|
_rows.update(id, content);
|
||||||
|
|
||||||
|
/// Marks [task] done now, or not done where [done] is false.
|
||||||
|
Future<void> complete(StoredTask task, {bool done = true}) async {
|
||||||
|
if (task.content case final content?) {
|
||||||
|
await update(
|
||||||
|
task.id,
|
||||||
|
content.copyWith(completedAt: () => done ? clock.now() : null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the task [id] with its sub-tasks, theirs too, together.
|
||||||
|
Future<void> delete(String id) async {
|
||||||
|
final tasks = await all();
|
||||||
|
await _rows.delete(_withDescendants({id}, tasks));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes every task in the project [projectId] (or, given, only those
|
||||||
|
/// in its section [sectionId]), with their sub-tasks, together.
|
||||||
|
Future<void> deleteIn(String projectId, {String? sectionId}) async {
|
||||||
|
final tasks = await all();
|
||||||
|
await _rows.delete(
|
||||||
|
_withDescendants({
|
||||||
|
for (final task in tasks)
|
||||||
|
if (task.content case final content?
|
||||||
|
when content.projectId == projectId &&
|
||||||
|
(sectionId == null || content.sectionId == sectionId))
|
||||||
|
task.id,
|
||||||
|
}, tasks),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes what it holds open.
|
||||||
|
Future<void> dispose() => _rows.dispose();
|
||||||
|
|
||||||
|
/// [ids] and every task below them.
|
||||||
|
static Set<String> _withDescendants(Set<String> ids, List<StoredTask> all) {
|
||||||
|
final found = {...ids};
|
||||||
|
var grew = true;
|
||||||
|
while (grew) {
|
||||||
|
grew = false;
|
||||||
|
for (final task in all) {
|
||||||
|
if (task.content?.parentId case final parent?
|
||||||
|
when found.contains(parent) && found.add(task.id)) {
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue