feat(todo): the person's tasks, sections and labels, sealed and synced (ADR 0033, D6)
A table's rows are sealed for their table and row by one SealedRows, which the repositories of projects, sections, tasks and labels share, each table with its own sync gateway, a deletion blanking its rows first in one transaction. A task's payload holds its title, description, project, section, parent, place, date (a day, an optional wall-clock time and duration), deadline, priority, labels and when it was done; deleting a task deletes its sub-tasks, and a project or a section goes with its tasks. Rows that don't open show locked; a device without the account's keys writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
128bcb127b
commit
bd3710ae58
26 changed files with 1557 additions and 224 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// [json] as a row's payload, before sealing: UTF-8 JSON naming its
|
||||
/// schema's [version] (ADR 0033, D6).
|
||||
ByteData encodeJson(int version, Map<String, Object?> json) =>
|
||||
ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode(jsonEncode({'schemaVersion': version, ...json})),
|
||||
),
|
||||
);
|
||||
|
||||
/// What [read] makes of the JSON [payload] holds; null for a payload of a
|
||||
/// schema newer than [version], one that is no JSON object, or one [read]
|
||||
/// can't read (it throws), each logged under [name]: a row is never read
|
||||
/// half.
|
||||
T? decodeJson<T>(
|
||||
ByteData payload, {
|
||||
required int version,
|
||||
required String name,
|
||||
required T Function(Map<String, Object?> json) read,
|
||||
}) {
|
||||
try {
|
||||
final json = jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(payload)),
|
||||
) as Map<String, Object?>;
|
||||
final schema = json['schemaVersion']! as int;
|
||||
if (schema > version) {
|
||||
log('A $name of schema $schema skipped', name: name);
|
||||
return null;
|
||||
}
|
||||
return read(json);
|
||||
} on Object catch (error) {
|
||||
log("A $name's payload not read: $error", name: name);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A synced row as kept: its id and its envelope, sealed (ADR 0033, D5).
|
||||
typedef SealedRow = ({String id, Envelope envelope});
|
||||
|
||||
/// Where the rows of one of Todo's synced tables are kept (`project`,
|
||||
/// `section`, `task`, `label`, D6), all alike: a sealed payload each.
|
||||
/// `SealedRows` seals and opens; this only keeps.
|
||||
abstract interface class SealedRowGateway() {
|
||||
/// Every row of the table.
|
||||
Future<List<SealedRow>> rows();
|
||||
|
||||
Future<void> insert(SealedRow row);
|
||||
|
||||
/// Replaces the row [id]'s envelope (an edit); nothing for one gone.
|
||||
Future<void> update(String id, Envelope envelope);
|
||||
|
||||
/// Deletes the rows [ids] together, each payload blanked first (ADR 0002,
|
||||
/// rule 2).
|
||||
Future<void> delete(Iterable<String> ids);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' show Uuid;
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
|
||||
/// A row as opened: its `id` and what it holds, or `content` null where it
|
||||
/// doesn't open here (another person's, readable, moved from another row,
|
||||
/// of a newer schema): shown as locked, never half.
|
||||
typedef Opened<T> = ({String id, T? content});
|
||||
|
||||
/// The rows of one of Todo's synced tables (ADR 0033, D5, D6), each sealed
|
||||
/// with the person's key for its table and row ([PayloadEnvelope]), so the
|
||||
/// instance can neither read one nor move it to another row; a readable row
|
||||
/// is one the instance made up. The repositories of projects, sections,
|
||||
/// tasks and labels are built on it.
|
||||
class SealedRows<T>(
|
||||
final SealedRowGateway _gateway,
|
||||
|
||||
/// The person's key.
|
||||
final TodoKeys keys, {
|
||||
|
||||
/// The table, which every payload is sealed for.
|
||||
required final String table,
|
||||
|
||||
/// [T] as its payload, before sealing.
|
||||
required final ByteData Function(T content) encode,
|
||||
|
||||
/// The [T] a payload holds; null where it can't be read.
|
||||
required final T? Function(ByteData payload) decode,
|
||||
}) {
|
||||
StreamSubscription<void>? _remote;
|
||||
|
||||
/// This device's changes, and those a sync brings while anyone listens.
|
||||
late final StreamController<void> _changes = StreamController.broadcast(
|
||||
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
|
||||
onCancel: () => _remote?.cancel(),
|
||||
);
|
||||
|
||||
late final _envelope = PayloadEnvelope(keys.cipher);
|
||||
|
||||
/// Fires when the rows changed, here or on the person's other devices.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// Every row, opened where it opens, by ID (so by when it was made, as
|
||||
/// UUID v7 tells).
|
||||
Future<List<Opened<T>>> all() async {
|
||||
await keys.refresh();
|
||||
final rows = await _gateway.rows()
|
||||
..sort((a, b) => a.id.compareTo(b.id));
|
||||
return [
|
||||
for (final row in rows)
|
||||
(
|
||||
id: row.id,
|
||||
content: switch (_open(row.envelope, row.id)) {
|
||||
final payload? => decode(payload),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether a row [id] is kept, opened or not.
|
||||
Future<bool> has(String id) async =>
|
||||
(await _gateway.rows()).any((row) => row.id == id);
|
||||
|
||||
/// Keeps a new row, under [id] where given (a keyed one), and returns its
|
||||
/// ID; throws [KeysMissing] where this device can't seal.
|
||||
Future<String> insert(T content, {String? id}) async {
|
||||
final rowId = id ?? const Uuid().v7();
|
||||
await _gateway.insert((id: rowId, envelope: await _sealed(rowId, content)));
|
||||
_changes.add(null);
|
||||
return rowId;
|
||||
}
|
||||
|
||||
/// Replaces the row [id]'s content; throws [KeysMissing] where this device
|
||||
/// can't seal.
|
||||
Future<void> update(String id, T content) async {
|
||||
await _gateway.update(id, await _sealed(id, content));
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the rows [ids] together.
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
await _gateway.delete(ids);
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
/// Closes what it holds open.
|
||||
Future<void> dispose() => _changes.close();
|
||||
|
||||
Future<Envelope> _sealed(String id, T content) async => _envelope.seal(
|
||||
encode(content),
|
||||
keyId: await keys.keyToWrite(),
|
||||
table: table,
|
||||
id: id,
|
||||
);
|
||||
|
||||
ByteData? _open(Envelope envelope, String id) {
|
||||
if (envelope.keyId == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_envelope.open(envelope, table: table, id: id)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_payload.dart';
|
||||
|
||||
/// The schema of a label's payload this app writes and reads.
|
||||
const labelSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing.
|
||||
ByteData encodeLabel(LabelContent content) => encodeJson(labelSchemaVersion, {
|
||||
'name': content.name,
|
||||
'color': ?content.color?.name,
|
||||
'position': content.position,
|
||||
});
|
||||
|
||||
/// The label [payload] holds, opened; null for one it can't read.
|
||||
LabelContent? decodeLabel(ByteData payload) => decodeJson(
|
||||
payload,
|
||||
version: labelSchemaVersion,
|
||||
name: 'Label',
|
||||
read: (json) => LabelContent(
|
||||
name: json['name']! as String,
|
||||
color: paletteColorNamed(json['color'] as String?),
|
||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||
),
|
||||
);
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
|
||||
/// The person's labels (ADR 0033, D6), each sealed with their key for its
|
||||
/// row (D5, [SealedRows]). A task naming a label gone shows without it.
|
||||
class LabelRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
||||
late final _rows = SealedRows<LabelContent>(
|
||||
_gateway,
|
||||
_keys,
|
||||
table: 'label',
|
||||
encode: encodeLabel,
|
||||
decode: decodeLabel,
|
||||
);
|
||||
|
||||
/// Fires when the labels changed, here or on another device.
|
||||
Stream<void> get changes => _rows.changes;
|
||||
|
||||
/// Every label, by when it was made.
|
||||
Future<List<StoredLabel>> all() async => [
|
||||
for (final (:id, :content) in await _rows.all()) StoredLabel(id, content),
|
||||
];
|
||||
|
||||
/// Keeps a new label and returns its ID; throws [KeysMissing] where this
|
||||
/// device can't seal.
|
||||
Future<String> add(LabelContent content) => _rows.insert(content);
|
||||
|
||||
Future<void> update(String id, LabelContent content) =>
|
||||
_rows.update(id, content);
|
||||
|
||||
Future<void> delete(String id) => _rows.delete([id]);
|
||||
|
||||
/// Closes what it holds open.
|
||||
Future<void> dispose() => _rows.dispose();
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// The person's labels on their local database ([SealedRowGateway]), which the
|
||||
/// sync engine keeps in step with their other devices.
|
||||
class SyncLabelGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements SealedRowGateway {
|
||||
@override
|
||||
Future<List<SealedRow>> rows() async => [
|
||||
for (final row in await protocol.Label.db.find(_db, orderBy: (t) => t.id))
|
||||
(
|
||||
id: row.id!.uuid,
|
||||
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(SealedRow row) async {
|
||||
await protocol.Label.db.insertRow(
|
||||
_db,
|
||||
protocol.Label(
|
||||
id: protocol.UuidValue.withValidation(row.id),
|
||||
keyId: row.envelope.keyId,
|
||||
nonce: row.envelope.nonce,
|
||||
payload: row.envelope.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.Label.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final row?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Label.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(
|
||||
keyId: envelope.keyId,
|
||||
nonce: envelope.nonce,
|
||||
payload: envelope.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
final uuids = <protocol.UuidValue>{
|
||||
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||
};
|
||||
if (uuids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _db.db.transaction((transaction) async {
|
||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||
// they last were, until the space goes.
|
||||
for (final row in await protocol.Label.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
)) {
|
||||
await protocol.Label.db.updateRow(
|
||||
_db,
|
||||
protocol.Label(
|
||||
id: row.id,
|
||||
spaceId: row.spaceId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.Label.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// What a label is (ADR 0033, D6): its [name], its [color] from halkyon_ui's
|
||||
/// palette (D9) and its [position] among the others.
|
||||
final class const LabelContent({
|
||||
required final String name,
|
||||
final PaletteColor? color,
|
||||
final double position = 0,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [name, color, position];
|
||||
}
|
||||
|
||||
/// A label as kept: its [id], and [content] null where it doesn't open.
|
||||
final class const StoredLabel(final String id, final LabelContent? content)
|
||||
with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, content];
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A `project` row as kept: its id and its envelope, sealed (ADR 0033, D5).
|
||||
typedef ProjectRow = ({String id, Envelope envelope});
|
||||
|
||||
/// Where the person's projects are kept: their synced `project` rows
|
||||
/// (D6). The repository seals and opens; this only keeps.
|
||||
abstract interface class ProjectGateway() {
|
||||
Future<List<ProjectRow>> projects();
|
||||
|
||||
Future<void> insert(ProjectRow project);
|
||||
|
||||
/// Replaces the project [id]'s envelope (an edit); nothing for one gone.
|
||||
Future<void> update(String id, Envelope envelope);
|
||||
|
||||
/// Deletes the project [id], its payload blanked first (ADR 0002, rule 2).
|
||||
Future<void> delete(String id);
|
||||
|
||||
/// Fires when a sync brought changes from the person's other devices.
|
||||
Stream<void> get remoteChanges;
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
|
||||
/// The schema of a section's payload this app writes and reads.
|
||||
const sectionSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing.
|
||||
ByteData encodeSection(SectionContent content) =>
|
||||
encodeJson(sectionSchemaVersion, {
|
||||
'name': content.name,
|
||||
'projectId': content.projectId,
|
||||
if (content.description.isNotEmpty) 'description': content.description,
|
||||
'position': content.position,
|
||||
});
|
||||
|
||||
/// The section [payload] holds, opened; null for one it can't read.
|
||||
SectionContent? decodeSection(ByteData payload) => decodeJson(
|
||||
payload,
|
||||
version: sectionSchemaVersion,
|
||||
name: 'Section',
|
||||
read: (json) => SectionContent(
|
||||
name: json['name']! as String,
|
||||
projectId: json['projectId']! as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||
),
|
||||
);
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
|
||||
/// The sections of the person's projects (ADR 0033, D6), each sealed with
|
||||
/// their key for its row (D5, [SealedRows]).
|
||||
class SectionRepository(
|
||||
final SealedRowGateway _gateway,
|
||||
final TodoKeys _keys,
|
||||
) {
|
||||
late final _rows = SealedRows<SectionContent>(
|
||||
_gateway,
|
||||
_keys,
|
||||
table: 'section',
|
||||
encode: encodeSection,
|
||||
decode: decodeSection,
|
||||
);
|
||||
|
||||
/// Fires when the sections changed, here or on another device.
|
||||
Stream<void> get changes => _rows.changes;
|
||||
|
||||
/// Every section, by when it was made.
|
||||
Future<List<StoredSection>> all() async => [
|
||||
for (final (:id, :content) in await _rows.all()) StoredSection(id, content),
|
||||
];
|
||||
|
||||
/// Keeps a new section and returns its ID; throws [KeysMissing] where
|
||||
/// this device can't seal.
|
||||
Future<String> add(SectionContent content) => _rows.insert(content);
|
||||
|
||||
Future<void> update(String id, SectionContent content) =>
|
||||
_rows.update(id, content);
|
||||
|
||||
/// Deletes the sections [ids] together (their tasks are the caller's).
|
||||
Future<void> delete(Iterable<String> ids) => _rows.delete(ids);
|
||||
|
||||
/// Closes what it holds open.
|
||||
Future<void> dispose() => _rows.dispose();
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// The person's sections on their local database ([SealedRowGateway]), which the
|
||||
/// sync engine keeps in step with their other devices.
|
||||
class SyncSectionGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements SealedRowGateway {
|
||||
@override
|
||||
Future<List<SealedRow>> rows() async => [
|
||||
for (final row in await protocol.Section.db.find(_db, orderBy: (t) => t.id))
|
||||
(
|
||||
id: row.id!.uuid,
|
||||
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(SealedRow row) async {
|
||||
await protocol.Section.db.insertRow(
|
||||
_db,
|
||||
protocol.Section(
|
||||
id: protocol.UuidValue.withValidation(row.id),
|
||||
keyId: row.envelope.keyId,
|
||||
nonce: row.envelope.nonce,
|
||||
payload: row.envelope.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.Section.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final row?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Section.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(
|
||||
keyId: envelope.keyId,
|
||||
nonce: envelope.nonce,
|
||||
payload: envelope.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
final uuids = <protocol.UuidValue>{
|
||||
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||
};
|
||||
if (uuids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _db.db.transaction((transaction) async {
|
||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||
// they last were, until the space goes.
|
||||
for (final row in await protocol.Section.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
)) {
|
||||
await protocol.Section.db.updateRow(
|
||||
_db,
|
||||
protocol.Section(
|
||||
id: row.id,
|
||||
spaceId: row.spaceId,
|
||||
payload: ByteData(0),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.Section.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What a section of a project is (ADR 0033, D6): its [name], a
|
||||
/// [description], the project it is in and its [position] there.
|
||||
final class const SectionContent({
|
||||
required final String name,
|
||||
required final String projectId,
|
||||
final String description = '',
|
||||
final double position = 0,
|
||||
}) with Equatable {
|
||||
SectionContent copyWith({
|
||||
String? name,
|
||||
String? projectId,
|
||||
String? description,
|
||||
double? position,
|
||||
}) => SectionContent(
|
||||
name: name ?? this.name,
|
||||
projectId: projectId ?? this.projectId,
|
||||
description: description ?? this.description,
|
||||
position: position ?? this.position,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, projectId, description, position];
|
||||
}
|
||||
|
||||
/// A section as kept: its [id], and [content] null where it doesn't open.
|
||||
final class const StoredSection(final String id, final SectionContent? content)
|
||||
with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [id, content];
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// The person's tasks on their local database ([SealedRowGateway]), which the
|
||||
/// sync engine keeps in step with their other devices.
|
||||
class SyncTaskGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements SealedRowGateway {
|
||||
@override
|
||||
Future<List<SealedRow>> rows() async => [
|
||||
for (final row in await protocol.Task.db.find(_db, orderBy: (t) => t.id))
|
||||
(
|
||||
id: row.id!.uuid,
|
||||
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(SealedRow row) async {
|
||||
await protocol.Task.db.insertRow(
|
||||
_db,
|
||||
protocol.Task(
|
||||
id: protocol.UuidValue.withValidation(row.id),
|
||||
keyId: row.envelope.keyId,
|
||||
nonce: row.envelope.nonce,
|
||||
payload: row.envelope.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (await protocol.Task.db.findById(
|
||||
_db,
|
||||
protocol.UuidValue.withValidation(id),
|
||||
)
|
||||
case final row?) {
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Task.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(
|
||||
keyId: envelope.keyId,
|
||||
nonce: envelope.nonce,
|
||||
payload: envelope.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(Iterable<String> ids) async {
|
||||
final uuids = <protocol.UuidValue>{
|
||||
for (final id in ids) protocol.UuidValue.withValidation(id),
|
||||
};
|
||||
if (uuids.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await _db.db.transaction((transaction) async {
|
||||
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
|
||||
// they last were, until the space goes.
|
||||
for (final row in await protocol.Task.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
)) {
|
||||
await protocol.Task.db.updateRow(
|
||||
_db,
|
||||
protocol.Task(id: row.id, spaceId: row.spaceId, payload: ByteData(0)),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
await protocol.Task.db.deleteWhere(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(uuids),
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// The schema of a task's payload this app writes and reads.
|
||||
const taskSchemaVersion = 1;
|
||||
|
||||
/// [content] as its row's payload, before sealing: UTF-8 JSON, days as
|
||||
/// `YYYY-MM-DD`, a time as minutes after midnight, a duration in minutes,
|
||||
/// the priority as its number, when it was done in UTC.
|
||||
ByteData encodeTask(TaskContent content) => encodeJson(taskSchemaVersion, {
|
||||
'title': content.title,
|
||||
'projectId': content.projectId,
|
||||
if (content.description.isNotEmpty) 'description': content.description,
|
||||
'sectionId': ?content.sectionId,
|
||||
'parentId': ?content.parentId,
|
||||
'position': content.position,
|
||||
if (content.date case final date?)
|
||||
'date': {
|
||||
'day': '${date.day}',
|
||||
'time': ?date.time,
|
||||
'minutes': ?date.duration?.inMinutes,
|
||||
},
|
||||
if (content.deadline case final deadline?) 'deadline': '$deadline',
|
||||
if (content.priority != Priority.none) 'priority': content.priority.number,
|
||||
if (content.labelIds.isNotEmpty) 'labelIds': content.labelIds,
|
||||
if (content.completedAt case final at?)
|
||||
'completedAt': at.toUtc().toIso8601String(),
|
||||
});
|
||||
|
||||
/// The task [payload] holds, opened; null for one of a newer schema or one
|
||||
/// it can't read: a task is never read half, so one whose date can't be
|
||||
/// read isn't read at all.
|
||||
TaskContent? decodeTask(ByteData payload) => decodeJson(
|
||||
payload,
|
||||
version: taskSchemaVersion,
|
||||
name: 'Task',
|
||||
read: (json) => TaskContent(
|
||||
title: json['title']! as String,
|
||||
projectId: json['projectId']! as String,
|
||||
description: json['description'] as String? ?? '',
|
||||
sectionId: json['sectionId'] as String?,
|
||||
parentId: json['parentId'] as String?,
|
||||
position: (json['position'] as num?)?.toDouble() ?? 0,
|
||||
date: switch (json['date']) {
|
||||
final Map<String, Object?> date => _date(date),
|
||||
null => null,
|
||||
_ => throw const FormatException('date'),
|
||||
},
|
||||
deadline: switch (json['deadline']) {
|
||||
final String day => Day.tryParse(day) ?? (throw const FormatException()),
|
||||
null => null,
|
||||
_ => throw const FormatException('deadline'),
|
||||
},
|
||||
priority: Priority.ofNumber(json['priority'] as int?),
|
||||
labelIds: [
|
||||
for (final id in json['labelIds'] as List<Object?>? ?? const [])
|
||||
id! as String,
|
||||
],
|
||||
completedAt: switch (json['completedAt']) {
|
||||
final String at => DateTime.parse(at).toLocal(),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
TaskDate _date(Map<String, Object?> json) {
|
||||
final day = Day.tryParse(json['day']! as String);
|
||||
final time = json['time'] as int?;
|
||||
if (day == null || (time != null && (time < 0 || time >= 24 * 60))) {
|
||||
throw const FormatException('date');
|
||||
}
|
||||
return TaskDate(
|
||||
day,
|
||||
time: time,
|
||||
duration: switch (json['minutes']) {
|
||||
final int minutes => Duration(minutes: minutes),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
/// The person's tasks (ADR 0033, D6), each sealed with their key for its row
|
||||
/// (D5, [SealedRows]): which project a task is in, its date and whether it
|
||||
/// is done are in its payload, so the instance learns none of it.
|
||||
class TaskRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
|
||||
late final _rows = SealedRows<TaskContent>(
|
||||
_gateway,
|
||||
_keys,
|
||||
table: 'task',
|
||||
encode: encodeTask,
|
||||
decode: decodeTask,
|
||||
);
|
||||
|
||||
/// Fires when the tasks changed, here or on the person's other devices.
|
||||
Stream<void> get changes => _rows.changes;
|
||||
|
||||
/// Every task, by when it was made; one that doesn't open here with its
|
||||
/// content null.
|
||||
Future<List<StoredTask>> all() async => [
|
||||
for (final (:id, :content) in await _rows.all()) StoredTask(id, content),
|
||||
];
|
||||
|
||||
/// Keeps a new task and returns its ID; throws [KeysMissing] where this
|
||||
/// device can't seal.
|
||||
Future<String> add(TaskContent content) => _rows.insert(content);
|
||||
|
||||
Future<void> update(String id, TaskContent content) =>
|
||||
_rows.update(id, content);
|
||||
|
||||
/// Marks [task] done now, or not done where [done] is false.
|
||||
Future<void> complete(StoredTask task, {bool done = true}) async {
|
||||
if (task.content case final content?) {
|
||||
await update(
|
||||
task.id,
|
||||
content.copyWith(completedAt: () => done ? clock.now() : null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the task [id] with its sub-tasks, theirs too, together.
|
||||
Future<void> delete(String id) async {
|
||||
final tasks = await all();
|
||||
await _rows.delete(_withDescendants({id}, tasks));
|
||||
}
|
||||
|
||||
/// Deletes every task in the project [projectId] (or, given, only those
|
||||
/// in its section [sectionId]), with their sub-tasks, together.
|
||||
Future<void> deleteIn(String projectId, {String? sectionId}) async {
|
||||
final tasks = await all();
|
||||
await _rows.delete(
|
||||
_withDescendants({
|
||||
for (final task in tasks)
|
||||
if (task.content case final content?
|
||||
when content.projectId == projectId &&
|
||||
(sectionId == null || content.sectionId == sectionId))
|
||||
task.id,
|
||||
}, tasks),
|
||||
);
|
||||
}
|
||||
|
||||
/// Closes what it holds open.
|
||||
Future<void> dispose() => _rows.dispose();
|
||||
|
||||
/// [ids] and every task below them.
|
||||
static Set<String> _withDescendants(Set<String> ids, List<StoredTask> all) {
|
||||
final found = {...ids};
|
||||
var grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (final task in all) {
|
||||
if (task.content?.parentId case final parent?
|
||||
when found.contains(parent) && found.add(task.id)) {
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue