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:
Tizian 「ティツィアーン」 2026-11-01 01:14:54 +01:00
commit bd3710ae58
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 1557 additions and 224 deletions

View file

@ -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.

View file

@ -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 {

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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,
};
}
}

View file

@ -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,
),
);

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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];
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -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,
); );
}); });

View file

@ -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,
),
);

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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];
}

View file

@ -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;
}

View file

@ -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,
},
);
}

View file

@ -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