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
26 changed files with 1557 additions and 224 deletions

View file

@ -9,8 +9,14 @@ import 'package:halkyon_todo_client/halkyon_todo_client.dart'
show Client, UuidValue;
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/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/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:path/path.dart' as p;
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_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
/// 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
@ -32,8 +39,34 @@ class TodoPerson(
/// Their projects, the Inbox among them, sealed and synced.
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,
}) 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.
@override
Future<void> dispose() async {
@ -232,7 +265,7 @@ class TodoDependencies({
}
/// 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.
Future<TodoPerson> _openPersonData(
Client client,
@ -271,6 +304,9 @@ Future<TodoPerson> _openPersonData(
);
await keys.refresh();
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
// device holds none of the account's keys.
unawaited(
@ -284,9 +320,15 @@ Future<TodoPerson> _openPersonData(
person,
sync: sync,
projects: projects,
sections: sections,
tasks: tasks,
labels: labels,
onDispose: () async {
try {
await projects.dispose();
await sections.dispose();
await tasks.dispose();
await labels.dispose();
await sync.dispose();
await db.close();
} 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 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.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
/// its palette name.
ByteData encodeProject(ProjectContent content) => ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
jsonEncode({
'schemaVersion': projectSchemaVersion,
'name': content.name,
'color': ?content.color?.name,
'position': content.position,
}),
),
),
);
ByteData encodeProject(ProjectContent content) =>
encodeJson(projectSchemaVersion, {
'name': content.name,
'color': ?content.color?.name,
'position': content.position,
});
/// 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.
ProjectContent? decodeProject(ByteData payload) {
try {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > projectSchemaVersion) {
log('A project of schema $version skipped', name: 'Project');
return null;
}
final color = json['color'] as String?;
return ProjectContent(
name: json['name']! as String,
color: PaletteColor.values.where((c) => c.name == color).firstOrNull,
position: (json['position'] as num?)?.toDouble() ?? 0,
);
} on Object catch (error) {
log("A project's payload not read: $error", name: 'Project');
return null;
}
}
ProjectContent? decodeProject(ByteData payload) => decodeJson(
payload,
version: projectSchemaVersion,
name: 'Project',
read: (json) => ProjectContent(
name: json['name']! as String,
color: paletteColorNamed(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
),
);
/// The palette's colour named [name]; none for a name it doesn't know.
PaletteColor? paletteColorNamed(String? name) =>
PaletteColor.values.where((color) => color.name == name).firstOrNull;

View file

@ -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/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/model/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
/// 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
/// ([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
/// instance can tell neither which row it is nor whose.
class ProjectRepository(final ProjectGateway _gateway, final TodoKeys _keys) {
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(),
class ProjectRepository(
final SealedRowGateway _gateway,
final TodoKeys _keys,
) {
late final _rows = SealedRows<ProjectContent>(
_gateway,
_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.
Stream<void> get changes => _changes.stream;
Stream<void> get changes => _rows.changes;
/// 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
/// they were made; one that doesn't open here with its content null.
Future<List<StoredProject>> all() async {
await _keys.refresh();
final inbox = _keys.unlocked ? inboxId : null;
final projects = [
for (final row in await _gateway.projects())
StoredProject(row.id, switch (_open(row.envelope, row.id)) {
final payload? => decodeProject(payload),
null => null,
}, inbox: row.id == inbox),
];
return projects..sort(_order);
final rows = await _rows.all();
final inbox = _rows.keys.unlocked ? inboxId : null;
return [
for (final (:id, :content) in rows)
StoredProject(id, content, inbox: id == inbox),
]..sort(_order);
}
/// Makes the Inbox where it isn't yet and returns its ID; throws
/// [KeysMissing] where this device can't seal.
Future<String> ensureInbox() async {
await _keys.refresh();
await _rows.keys.refresh();
final id = inboxId;
if (!(await _gateway.projects()).any((row) => row.id == id)) {
await _gateway.insert((
id: id,
envelope: await _sealed(id, ProjectContent.inbox),
));
_changes.add(null);
if (!await _rows.has(id)) {
await _rows.insert(ProjectContent.inbox, id: id);
}
return id;
}
/// Keeps a new project and returns its ID; throws [KeysMissing] where this
/// device can't seal.
Future<String> save(ProjectContent content) async {
final id = const Uuid().v7();
await _gateway.insert((id: id, envelope: await _sealed(id, content)));
_changes.add(null);
return id;
}
Future<String> save(ProjectContent content) => _rows.insert(content);
Future<void> update(String id, ProjectContent content) async {
await _gateway.update(id, await _sealed(id, content));
_changes.add(null);
}
Future<void> update(String id, ProjectContent content) =>
_rows.update(id, content);
/// 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 {
if (_keys.unlocked && id == inboxId) {
if (_rows.keys.unlocked && id == inboxId) {
throw InboxKept();
}
await _gateway.delete(id);
_changes.add(null);
await _rows.delete([id]);
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
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,
};
}
Future<void> dispose() => _rows.dispose();
static int _order(StoredProject a, StoredProject b) {
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_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';
/// [ProjectGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
/// The person's projects on their local database ([SealedRowGateway]), which the
/// sync engine keeps in step with their other devices.
class SyncProjectGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements ProjectGateway {
) implements SealedRowGateway {
@override
Future<List<ProjectRow>> projects() async => [
for (final project in await protocol.Project.db.find(
_db,
orderBy: (t) => t.id,
))
Future<List<SealedRow>> rows() async => [
for (final row in await protocol.Project.db.find(_db, orderBy: (t) => t.id))
(
id: project.id!.uuid,
envelope: (
keyId: project.keyId,
nonce: project.nonce,
payload: project.payload,
),
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<void> insert(ProjectRow project) async {
Future<void> insert(SealedRow row) async {
await protocol.Project.db.insertRow(
_db,
protocol.Project(
id: protocol.UuidValue.withValidation(project.id),
keyId: project.envelope.keyId,
nonce: project.envelope.nonce,
payload: project.envelope.payload,
id: protocol.UuidValue.withValidation(row.id),
keyId: row.envelope.keyId,
nonce: row.envelope.nonce,
payload: row.envelope.payload,
),
);
_sync.requestSync();
@ -47,11 +40,11 @@ class SyncProjectGateway(
_db,
protocol.UuidValue.withValidation(id),
)
case final project?) {
case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.Project.db.updateRow(
_db,
project.copyWith(
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
@ -63,22 +56,26 @@ class SyncProjectGateway(
}
@override
Future<void> delete(String id) async {
final uuid = protocol.UuidValue.withValidation(id);
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.
if (await protocol.Project.db.findById(
_db,
uuid,
transaction: transaction,
)
case final project?) {
for (final row in await protocol.Project.db.find(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
)) {
await protocol.Project.db.updateRow(
_db,
protocol.Project(
id: project.id,
spaceId: project.spaceId,
id: row.id,
spaceId: row.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
@ -87,7 +84,7 @@ class SyncProjectGateway(
}
await protocol.Project.db.deleteWhere(
_db,
where: (t) => t.id.equals(uuid),
where: (t) => t.id.inSet(uuids),
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;
}
}

View file

@ -0,0 +1,171 @@
import 'package:equatable/equatable.dart';
/// A day of the calendar, as a task's date and deadline name it (ADR 0033,
/// D6): the person's own, the same wherever they are.
final class const Day(final int year, final int month, final int day)
with Equatable
implements Comparable<Day> {
/// The day [at] falls on, where it is.
factory of(DateTime at) => Day(at.year, at.month, at.day);
/// The day as `YYYY-MM-DD`; null for anything else.
static Day? tryParse(String text) {
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(text);
if (match == null) {
return null;
}
final day = Day(
int.parse(match[1]!),
int.parse(match[2]!),
int.parse(match[3]!),
);
// No 31 February: the day must be one.
final date = day.toDate();
return date.month == day.month && date.day == day.day ? day : null;
}
/// The day's start, local time.
DateTime toDate() => DateTime(year, month, day);
/// The day [days] later (or earlier, below zero).
Day plus(int days) => Day.of(DateTime(year, month, day + days));
/// How many days [other] comes after this one.
int daysUntil(Day other) => DateTime.utc(
other.year,
other.month,
other.day,
).difference(DateTime.utc(year, month, day)).inDays;
@override
int compareTo(Day other) => daysUntil(other).sign * -1;
bool operator <(Day other) => compareTo(other) < 0;
bool operator >(Day other) => compareTo(other) > 0;
@override
String toString() =>
'${'$year'.padLeft(4, '0')}-${'$month'.padLeft(2, '0')}-'
'${'$day'.padLeft(2, '0')}';
@override
List<Object?> get props => [year, month, day];
}
/// When a task is to be done (ADR 0033, D6): a [day], and on it a [time]
/// (minutes after midnight, the person's wall clock) with a [duration]
/// where one is given. A time without its zone means the same hour wherever
/// the person is, as a paper diary has it; reminders (step 4) may add the
/// zone.
final class const TaskDate(
final Day day, {
final int? time,
final Duration? duration,
}) with Equatable {
@override
List<Object?> get props => [day, time, duration];
}
/// How urgent a task is (D6): [p1] the most, [none] not marked.
enum Priority() {
p1,
p2,
p3,
none;
/// The priority numbered [number] (1 to 4); none for any other.
static Priority ofNumber(int? number) => switch (number) {
1 => p1,
2 => p2,
3 => p3,
_ => none,
};
/// Its number, 1 to 4.
int get number => index + 1;
}
/// What a task is (ADR 0033, D6), as its sealed payload holds it.
final class const TaskContent({
/// What is to be done.
required final String title,
/// The project it is in.
required final String projectId,
final String description = '',
/// The project's section it is in; none: above the sections.
final String? sectionId,
/// The task it is a sub-task of; none: a task of its own.
final String? parentId,
/// Its place among its siblings, smaller first.
final double position = 0,
/// When it is to be done; none: some time.
final TaskDate? date,
/// The day it must be done by, apart from [date] (as Todoist's).
final Day? deadline,
final Priority priority = Priority.none,
/// Its labels' IDs.
final List<String> labelIds = const [],
/// When it was done; none: not yet.
final DateTime? completedAt,
}) with Equatable {
/// Whether it is done.
bool get completed => completedAt != null;
TaskContent copyWith({
String? title,
String? projectId,
String? description,
String? Function()? sectionId,
String? Function()? parentId,
double? position,
TaskDate? Function()? date,
Day? Function()? deadline,
Priority? priority,
List<String>? labelIds,
DateTime? Function()? completedAt,
}) => TaskContent(
title: title ?? this.title,
projectId: projectId ?? this.projectId,
description: description ?? this.description,
sectionId: sectionId == null ? this.sectionId : sectionId(),
parentId: parentId == null ? this.parentId : parentId(),
position: position ?? this.position,
date: date == null ? this.date : date(),
deadline: deadline == null ? this.deadline : deadline(),
priority: priority ?? this.priority,
labelIds: labelIds ?? this.labelIds,
completedAt: completedAt == null ? this.completedAt : completedAt(),
);
@override
List<Object?> get props => [
title,
projectId,
description,
sectionId,
parentId,
position,
date,
deadline,
priority,
labelIds,
completedAt,
];
}
/// A task as the person's devices keep it: its [id] and what it is, or
/// [content] null where it doesn't open here: shown as locked, never half.
final class const StoredTask(final String id, final TaskContent? content)
with Equatable {
@override
List<Object?> get props => [id, content];
}

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