feat: notes in studysets, synced and merged as cards are, with tags

Server: three synced tables, only additions (migration
20261002191122122-notes): `note` (its studyset, a fractional position),
`note_revision` (append-only, as a card's: parents, author, report,
the payload {schemaVersion, title, body}) and `note_tag` (one row per
assignment, never updated, as `card_tag`). Each indexes `spaceId`, and
everything goes with its space, studyset, note or tag. A migration of
synced tables locks app builds from before it out of syncing (ADR 0002,
rule 3): cheap now, before the first release.

App, `feature/note`:
- `NoteContent` is a title and rich text, merged apart (ADR 0003): a
  title changed on one device and the body on another both survive, a
  body changed on two is a conflict.
- `NoteRepository` creates a note at the end of its studyset, saves an
  edit on the revisions it started from (`saveEdit`, as the card
  composer now does), says when a note was last edited (its newest
  head's UUID v7), counts notes per studyset, and deletes a note's
  revisions blanked first (ADR 0002, rule 2).
- Notes share the person's tags: `setTagsOfNote`, and folding tags of
  one name or undoing a tag's deletion covers notes too.
- The delayed deletion with its Undo moves to `core/async`
  (`DelayedDeletions`): `StudysetDeletions` builds on it and now takes
  a studyset's notes too; `NoteDeletions` deletes a single note.

Tests: content, merge, payload, repository, deletions, tags, the device
migration guard, and sync tests against the server (four).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-02 21:53:56 +02:00
commit f3b56f2c0c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
31 changed files with 25469 additions and 131 deletions

View file

@ -0,0 +1,91 @@
import 'dart:async';
import 'dart:developer';
/// Deletions that can be undone for a moment (decided 2026-10-01): what is
/// deleted leaves every list at once ([pending]), and only once the time to
/// undo is over does [_deleteNow] run, since blanking can't be undone (ADR
/// 0002, rule 2).
///
/// It belongs to the person's data, so a deletion outlives the screen it
/// started on, and it closes with the person's data: a deletion still
/// waiting then is dropped, and what it would have deleted stays.
class DelayedDeletions(
/// Deletes an item, given its id, for good.
final Future<void> Function(String id) _deleteNow, {
/// Names the log a failed deletion goes to.
final String _logName = 'Deletions',
/// How long a deletion can be undone: the three seconds the message
/// offering it shows, and a moment for a late tap.
final Duration _undoWindow = const Duration(seconds: 4),
}) {
/// The deletions that can still be undone, by the item's id.
final _waiting = <String, Timer>{};
/// The deletions under way, too late to undo.
final _deleting = <String, Future<void>>{};
final _changes = StreamController<Set<String>>.broadcast();
/// The items whose deletion waits or is under way: lists leave them out.
Set<String> get pending => {..._waiting.keys, ..._deleting.keys};
/// [pending], now and after every change.
Stream<Set<String>> watchPending() => Stream.multi((listener) {
listener.add(pending);
final subscription = _changes.stream.listen(listener.add);
listener.onCancel = subscription.cancel;
});
/// Deletes the item [id] once the time to undo that is over.
void delete(String id) {
if (_deleting.containsKey(id)) {
return;
}
_waiting[id]?.cancel();
_waiting[id] = Timer(_undoWindow, () => _start(id));
_changed();
}
/// Keeps the item [id] after all, while its deletion still waits.
void undo(String id) {
if (_waiting.remove(id) case final timer?) {
timer.cancel();
_changed();
}
}
/// Drops the deletions still waiting, and lets those under way finish.
Future<void> dispose() async {
for (final timer in _waiting.values) {
timer.cancel();
}
_waiting.clear();
await Future.wait(_deleting.values);
await _changes.close();
}
void _start(String id) {
_waiting.remove(id);
_deleting[id] = _guarded(id).whenComplete(() {
_deleting.remove(id);
_changed();
});
}
Future<void> _guarded(String id) async {
try {
await _deleteNow(id);
} on Object catch (error) {
// What is left shows again; deleting it once more retries.
log('Deleting $id failed', name: _logName, error: error);
}
}
void _changed() {
if (!_changes.isClosed) {
_changes.add(pending);
}
}
}

View file

@ -21,6 +21,9 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/sync_note_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -153,13 +156,18 @@ Future<PersonDependencies> _openPerson(
final cards = CardRepository(
SyncCardGateway(db, sync, person.authUserId),
);
final notes = NoteRepository(
SyncNoteGateway(db, sync, person.authUserId),
);
final tags = TagRepository(
SyncTagGateway(db, sync),
cardChanges: cards.changes,
noteChanges: notes.changes,
);
final settings = SettingsRepository(SyncSettingsGateway(db, sync));
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
final deletions = StudysetDeletions(studysets, cards);
final deletions = StudysetDeletions(studysets, cards, notes);
final noteDeletions = NoteDeletions(notes);
final assets = AssetRepository(
SyncAssetGateway(db, sync),
ServerpodAssetServerGateway(client),
@ -170,21 +178,25 @@ Future<PersonDependencies> _openPerson(
return PersonDependencies(
studysets: studysets,
cards: cards,
notes: notes,
reviews: reviews,
tags: tags,
settings: settings,
assets: assets,
deletions: deletions,
noteDeletions: noteDeletions,
onDispose: () async {
try {
// Deletions first: those still waiting are dropped, those under way
// finish while their repositories are open.
await deletions.dispose();
await noteDeletions.dispose();
await studysets.dispose();
await tags.dispose();
await settings.dispose();
await reviews.dispose();
await cards.dispose();
await notes.dispose();
await assets.dispose();
await sync.dispose();
await db.close();

View file

@ -1,5 +1,7 @@
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -18,10 +20,13 @@ class PersonDependencies({
/// The person's flashcards.
required final CardRepository cards,
/// The person's notes.
required final NoteRepository notes,
/// The person's reviews.
required final ReviewRepository reviews,
/// The person's tags and which cards have them.
/// The person's tags and which cards and notes have them.
required final TagRepository tags,
/// The person's settings, which follow them to every device.
@ -32,6 +37,9 @@ class PersonDependencies({
/// The studyset deletions that can still be undone.
required final StudysetDeletions deletions,
/// The note deletions that can still be undone.
required final NoteDeletions noteDeletions,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -0,0 +1,10 @@
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
/// The person's note deletions, which can be undone for a moment (see
/// [DelayedDeletions]). Once the time to undo is over, the note is blanked
/// and deleted.
class NoteDeletions(NoteRepository notes, {super.undoWindow})
extends DelayedDeletions {
this : super(notes.delete, logName: 'Notes');
}

View file

@ -0,0 +1,54 @@
import 'dart:typed_data';
/// A note's row as the device's database stores it.
typedef NoteRow = ({String id, String position});
/// A note wherever it is: its id and its studyset.
typedef NoteStampRow = ({String id, String studysetId});
/// A note revision's row as the device's database stores it, with when it
/// was written (from its UUID v7 id; null for an id that doesn't say).
typedef NoteRevisionRow = ({
String id,
String noteId,
Set<String> parents,
ByteData payload,
DateTime? writtenAt,
});
/// The boundary to the notes in this person's local, synced database. Tests
/// replace it with a fake.
abstract interface class NoteGateway() {
/// The notes of the studyset [studysetId] on this device, unordered.
Future<List<NoteRow>> notesOf(String studysetId);
/// Every note of the person on this device, unordered.
Future<List<NoteStampRow>> allNotes();
/// The revisions of the notes [noteIds], unordered.
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds);
/// Stores a new note with its first revision; returns both ids.
Future<({String noteId, String revisionId})> insertNote({
required String studysetId,
required String position,
required ByteData payload,
});
/// Appends a revision to the note [noteId] that replaces [parents];
/// returns its id.
Future<String> insertRevision({
required String noteId,
required Set<String> parents,
required ByteData payload,
});
/// Blanks the payload of every revision of the note [noteId].
Future<void> blankRevisions(String noteId);
/// Deletes the note [noteId], its revisions and its tags.
Future<void> deleteNote(String noteId);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,33 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
// A note revision's content as its synced row stores it (see
// core/content/payload). Schema 1: the title and the body.
/// The schema this app writes.
const noteSchemaVersion = 1;
/// Encodes a note's content into a revision's payload.
ByteData encodeNotePayload(NoteContent content) =>
encodePayload(noteSchemaVersion, {
'title': content.title,
'body': content.body.toJson(),
});
/// Decodes a note revision's payload.
///
/// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know.
NoteContent decodeNotePayload(ByteData payload) {
final json = decodePayload(payload, newestSchema: noteSchemaVersion);
if (json case {
'title': final String title,
'body': final Map<String, Object?> body,
}) {
return NoteContent(title: title, body: RichContent.fromJson(body));
}
throw const FormatException('Not a note payload.');
}

View file

@ -0,0 +1,193 @@
import 'dart:async';
import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_revision.dart';
/// The person's notes: content kept as append-only revisions (ADR 0001,
/// rule 2), merged title and body apart when devices edited offline (ADR
/// 0003), ordered by a fractional index.
class NoteRepository(final NoteGateway _gateway) {
this {
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
}
final _changes = StreamController<void>.broadcast();
late final StreamSubscription<void> _remoteChanges;
/// Fires after every change of any note, made on this device or synced
/// from another.
Stream<void> get changes => _changes.stream;
/// The notes of [studysetId] in order, now and after every change, local
/// or synced. A read that fails arrives as an error; the next change reads
/// again.
Stream<List<Note>> watch(String studysetId) => Stream.multi((listener) {
var cancelled = false;
var reading = Future<void>.value();
// One read after another, so an older list never overtakes a newer one.
void read() => reading = reading.then((_) async {
if (cancelled) {
return;
}
try {
final notes = await notesOf(studysetId);
if (!cancelled) {
listener.add(notes);
}
} on Object catch (error, st) {
log('Reading notes failed', name: 'Notes', error: error);
if (!cancelled) {
listener.addError(error, st);
}
}
});
final subscription = _changes.stream.listen((_) => read());
listener.onCancel = () {
cancelled = true;
return subscription.cancel();
};
read();
});
/// The notes of [studysetId] on this device right now, in order, read
/// once; [watch] follows every change.
Future<List<Note>> notesOf(String studysetId) async {
final rows = await _gateway.notesOf(studysetId);
final revisionsByNote = <String, List<NoteRevision>>{};
for (final row in await _gateway.revisionsOf({
for (final note in rows) note.id,
})) {
if (_decode(row) case final revision?) {
(revisionsByNote[row.noteId] ??= []).add(revision);
}
}
return [
for (final row in rows)
if (revisionsByNote[row.id] case final revisions?)
if (mergeNoteRevisions(revisions) case final merged?)
Note(
id: row.id,
position: row.position,
content: merged.content,
heads: merged.heads,
conflicts: merged.conflicts,
editedAt: _newestOf(revisions, merged.heads)?.writtenAt,
),
]..sort(
(a, b) => switch (a.position.compareTo(b.position)) {
0 => a.id.compareTo(b.id),
final order => order,
},
);
}
/// How many notes each studyset holds, by the studyset's id; studysets
/// without notes are left out.
Future<Map<String, int>> countsByStudyset() async {
final counts = <String, int>{};
for (final note in await _gateway.allNotes()) {
counts.update(note.studysetId, (count) => count + 1, ifAbsent: () => 1);
}
return counts;
}
/// Creates a note with [content] at the end of [studysetId]; returns its
/// id and its first revision's, for the next edit to build on.
Future<({String noteId, String revisionId})> create(
String studysetId,
NoteContent content,
) async {
String? last;
for (final note in await _gateway.notesOf(studysetId)) {
if (last == null || note.position.compareTo(last) > 0) {
last = note.position;
}
}
final ids = await _gateway.insertNote(
studysetId: studysetId,
position: FractionalIndexing.generateKeyBetween(last, null),
payload: encodeNotePayload(content.normalized),
);
_changes.add(null);
return ids;
}
/// Stores [content] as the next revision of the note [noteId], replacing
/// [basedOn]: the revisions the edit started from (all current heads to
/// settle a conflict). What another device wrote meanwhile stays a head of
/// its own and merges with this one (ADR 0003), so it is never replaced
/// unseen. Returns the new revision's id, for the next edit to build on.
Future<String> saveEdit(
String noteId,
NoteContent content, {
required Set<String> basedOn,
}) async {
final id = await _gateway.insertRevision(
noteId: noteId,
parents: basedOn,
payload: encodeNotePayload(content.normalized),
);
_changes.add(null);
return id;
}
/// Deletes the note [noteId]. Its revisions are blanked first: the sync
/// engine keeps deleted rows until the whole space is deleted (ADR 0002,
/// rule 2).
Future<void> delete(String noteId) async {
await _gateway.blankRevisions(noteId);
await _gateway.deleteNote(noteId);
_changes.add(null);
}
/// Deletes every note of [studysetId] as [delete] does, before the
/// studyset itself goes.
Future<void> deleteAllOf(String studysetId) async {
for (final note in await _gateway.notesOf(studysetId)) {
await _gateway.blankRevisions(note.id);
await _gateway.deleteNote(note.id);
}
_changes.add(null);
}
/// Stops following remote changes.
Future<void> dispose() async {
await _remoteChanges.cancel();
await _changes.close();
}
/// The newest of [revisions] among [heads] (UUID v7 ids sort by creation),
/// or null.
NoteRevision? _newestOf(List<NoteRevision> revisions, Set<String> heads) {
NoteRevision? newest;
for (final revision in revisions) {
if (heads.contains(revision.id) &&
(newest == null || revision.id.compareTo(newest.id) > 0)) {
newest = revision;
}
}
return newest;
}
/// The revision in [row], or null for one this app can't read: written by
/// a newer app version, or blanked.
NoteRevision? _decode(NoteRevisionRow row) {
try {
return NoteRevision(
id: row.id,
parents: row.parents,
content: decodeNotePayload(row.payload),
writtenAt: row.writtenAt,
);
} on FormatException catch (error) {
log('Skipped note revision ${row.id}: ${error.message}', name: 'Notes');
return null;
}
}
}

View file

@ -0,0 +1,135 @@
import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [NoteGateway] on the person's local database, which the sync engine keeps
/// in step with their other devices. Revisions record [_authorId] as their
/// author.
class SyncNoteGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
final String _authorId,
) implements NoteGateway {
@override
Future<List<NoteRow>> notesOf(String studysetId) async => [
for (final note in await protocol.Note.db.find(
_db,
where: (t) =>
t.studysetId.equals(protocol.UuidValue.withValidation(studysetId)),
))
(id: note.id!.uuid, position: note.position),
];
@override
Future<List<NoteStampRow>> allNotes() async => [
for (final note in await protocol.Note.db.find(_db))
(id: note.id!.uuid, studysetId: note.studysetId.uuid),
];
@override
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds) async {
if (noteIds.isEmpty) {
return const [];
}
return [
for (final revision in await protocol.NoteRevision.db.find(
_db,
where: (t) => t.noteId.inSet({
for (final id in noteIds) protocol.UuidValue.withValidation(id),
}),
))
(
id: revision.id!.uuid,
noteId: revision.noteId.uuid,
parents: {for (final parent in revision.parents) parent.uuid},
payload: revision.payload,
writtenAt: uuidV7Time(revision.id!.uuid),
),
];
}
@override
Future<({String noteId, String revisionId})> insertNote({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final note = await protocol.Note.db.insertRow(
_db,
protocol.Note(
studysetId: protocol.UuidValue.withValidation(studysetId),
position: position,
),
);
final revisionId = await insertRevision(
noteId: note.id!.uuid,
parents: const {},
payload: payload,
);
return (noteId: note.id!.uuid, revisionId: revisionId);
}
@override
Future<String> insertRevision({
required String noteId,
required Set<String> parents,
required ByteData payload,
}) async {
final revision = await protocol.NoteRevision.db.insertRow(
_db,
protocol.NoteRevision(
noteId: protocol.UuidValue.withValidation(noteId),
parents: [
for (final parent in parents)
protocol.UuidValue.withValidation(parent),
],
authorId: protocol.UuidValue.withValidation(_authorId),
payload: payload,
),
);
_sync.requestSync();
return revision.id!.uuid;
}
@override
Future<void> blankRevisions(String noteId) async {
final revisions = await protocol.NoteRevision.db.find(
_db,
where: (t) => t.noteId.equals(protocol.UuidValue.withValidation(noteId)),
);
// A column-scoped update, which syncs like any other edit (ADR 0002,
// rule 2).
await protocol.NoteRevision.db.update(
_db,
[
for (final revision in revisions)
revision.copyWith(payload: redactedPayload),
],
columns: (t) => [t.payload],
);
_sync.requestSync();
}
@override
Future<void> deleteNote(String noteId) async {
final id = protocol.UuidValue.withValidation(noteId);
await protocol.NoteTag.db.deleteWhere(
_db,
where: (t) => t.noteId.equals(id),
);
await protocol.NoteRevision.db.deleteWhere(
_db,
where: (t) => t.noteId.equals(id),
);
await protocol.Note.db.deleteWhere(_db, where: (t) => t.id.equals(id));
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
/// A note as the app shows it: its revisions merged (`mergeNoteRevisions`).
final class const Note({
/// A UUID v7, made on the device that created it.
required final String id,
/// Where the note stands among its studyset's notes: a fractional index.
required final String position,
/// The current content. For a field in [conflicts], the newest version
/// shows until the person chooses.
required final NoteContent content,
/// The revisions its content is merged from: all current heads.
required final Set<String> heads,
/// The fields changed differently on different devices, with every
/// version, oldest first.
final Map<NoteField, List<Object?>> conflicts = const {},
/// When it was last edited, here or on another device: its newest
/// revision; null when the revision doesn't say.
final DateTime? editedAt,
}) with Equatable {
/// Whether devices changed a field differently and the person has to
/// choose.
bool get hasConflict => conflicts.isNotEmpty;
@override
List<Object?> get props => [
id,
position,
content,
heads,
conflicts,
editedAt,
];
}

View file

@ -0,0 +1,47 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// The parts of a note that merge separately when devices edited it while
/// offline (ADR 0003): a title changed on one device and the body on another
/// both survive.
enum NoteField() {
/// The title.
title,
/// The text.
body,
}
/// What a note holds: a title and rich text.
final class const NoteContent({
/// The title; empty for an untitled note.
final String title = '',
/// The text.
required final RichContent body,
}) with Equatable implements MergeableContent<NoteField, NoteContent> {
/// A note with no title and no text.
factory empty() => NoteContent(body: RichContent.empty());
@override
Object? operator [](NoteField field) => switch (field) {
NoteField.title => title,
NoteField.body => body,
};
@override
NoteContent withField(NoteField field, Object? value) => NoteContent(
title: field == NoteField.title ? value! as String : title,
body: field == NoteField.body ? value! as RichContent : body,
);
/// Whether it holds nothing: no title and no text.
bool get isBlank => title.trim().isEmpty && body.isBlank;
/// This content as it is stored: the title without surrounding spaces.
NoteContent get normalized => NoteContent(title: title.trim(), body: body);
@override
List<Object?> get props => [title, body];
}

View file

@ -0,0 +1,11 @@
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_revision.dart';
/// A note's content, merged from its revisions by [mergeNoteRevisions].
typedef MergedNote = Merged<NoteField, NoteContent>;
/// Merges a note's [revisions] title and body apart, or null without any:
/// see [mergeRevisionsOf].
MergedNote? mergeNoteRevisions(Iterable<NoteRevision> revisions) =>
mergeRevisionsOf(revisions, fields: NoteField.values);

View file

@ -0,0 +1,24 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
/// One version of a note's content, never changed once written (ADR 0001,
/// rule 2): editing appends a revision whose [parents] are the versions the
/// edit started from.
final class const NoteRevision({
/// A UUID v7, made on the device that wrote it; ids sort by creation.
@override required final String id,
/// The revisions this one replaces: none for a note's first, several when
/// it resolves a conflict.
@override required final Set<String> parents,
/// The note's content as of this revision.
@override required final NoteContent content,
/// When it was written; null for an id that doesn't say.
final DateTime? writtenAt,
}) with Equatable implements Revision<NoteContent> {
@override
List<Object?> get props => [id, parents, content, writtenAt];
}

View file

@ -1,102 +1,37 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_learn_flutter/src/core/async/delayed_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// The person's studyset deletions, which can be undone for a moment
/// (decided 2026-10-01). A deleted studyset leaves every list at once; only
/// once the time to undo is over are the studysets of its subtree and their
/// cards blanked and deleted, since blanking can't be undone (ADR 0002, rule
/// 2).
///
/// It belongs to the person's data, so a deletion outlives the screen it
/// started on (deleting a studyset from its own screen closes that screen),
/// and it closes with the person's data: a deletion still waiting then is
/// dropped, and the studyset stays.
/// The person's studyset deletions, which can be undone for a moment (see
/// [DelayedDeletions]). Once the time to undo is over, the studysets of the
/// deleted one's subtree are blanked and deleted with their cards and notes.
class StudysetDeletions(
final StudysetRepository _studysets,
final CardRepository _cards, {
StudysetRepository studysets,
CardRepository cards,
NoteRepository notes, {
super.undoWindow,
}) extends DelayedDeletions {
this
: super(
(id) => _deleteSubtree(id, studysets, cards, notes),
logName: 'Studysets',
);
}
/// How long a deletion can be undone: the three seconds the message
/// offering it shows, and a moment for a late tap.
final Duration _undoWindow = const Duration(seconds: 4),
}) {
/// The deletions that can still be undone, by the studyset's id.
final _waiting = <String, Timer>{};
/// The deletions under way, too late to undo.
final _deleting = <String, Future<void>>{};
final _changes = StreamController<Set<String>>.broadcast();
/// The studysets whose deletion waits or is under way: lists leave them
/// out, with everything inside them.
Set<String> get pending => {..._waiting.keys, ..._deleting.keys};
/// [pending], now and after every change.
Stream<Set<String>> watchPending() => Stream.multi((listener) {
listener.add(pending);
final subscription = _changes.stream.listen(listener.add);
listener.onCancel = subscription.cancel;
});
/// Deletes the studyset [id] with everything inside it, once the time to
/// undo that is over.
void delete(String id) {
if (_deleting.containsKey(id)) {
return;
}
_waiting[id]?.cancel();
_waiting[id] = Timer(_undoWindow, () => _deleteNow(id));
_changed();
}
/// Keeps the studyset [id] after all, while its deletion still waits.
void undo(String id) {
if (_waiting.remove(id) case final timer?) {
timer.cancel();
_changed();
}
}
/// Drops the deletions still waiting, and lets those under way finish.
Future<void> dispose() async {
for (final timer in _waiting.values) {
timer.cancel();
}
_waiting.clear();
await Future.wait(_deleting.values);
await _changes.close();
}
void _deleteNow(String id) {
_waiting.remove(id);
_deleting[id] = _deleteSubtree(id).whenComplete(() {
_deleting.remove(id);
_changed();
});
}
Future<void> _deleteSubtree(String id) async {
try {
// What is inside first, so no studyset is ever left without its
// parent; each studyset's cards before it, so no card content
// outlives its studyset.
for (final studysetId in subtreeOf(id, await _studysets.all()).reversed) {
await _cards.deleteAllOf(studysetId);
await _studysets.delete(studysetId);
}
} on Object catch (error) {
// What is left shows again; deleting it once more retries.
log('Deleting studyset $id failed', name: 'Studysets', error: error);
}
}
void _changed() {
if (!_changes.isClosed) {
_changes.add(pending);
}
Future<void> _deleteSubtree(
String id,
StudysetRepository studysets,
CardRepository cards,
NoteRepository notes,
) async {
// What is inside first, so no studyset is ever left without its parent;
// each studyset's cards and notes before it, so no content outlives its
// studyset.
for (final studysetId in subtreeOf(id, await studysets.all()).reversed) {
await cards.deleteAllOf(studysetId);
await notes.deleteAllOf(studysetId);
await studysets.delete(studysetId);
}
}

View file

@ -27,6 +27,16 @@ class SyncTagGateway(
),
];
@override
Future<List<NoteTagRow>> noteTags() async => [
for (final noteTag in await protocol.NoteTag.db.find(_db))
(
id: noteTag.id!.uuid,
noteId: noteTag.noteId.uuid,
tagId: noteTag.tagId.uuid,
),
];
@override
Future<String> insertTag(ByteData payload) async {
final tag = await protocol.Tag.db.insertRow(
@ -58,6 +68,10 @@ class SyncTagGateway(
_db,
where: (t) => t.tagId.equals(_uuid(id)),
);
await protocol.NoteTag.db.deleteWhere(
_db,
where: (t) => t.tagId.equals(_uuid(id)),
);
await protocol.Tag.db.deleteWhere(
_db,
where: (t) => t.id.equals(_uuid(id)),
@ -95,6 +109,36 @@ class SyncTagGateway(
_sync.requestSync();
}
@override
Future<void> insertNoteTags(
Iterable<({String noteId, String tagId})> assignments,
) async {
final rows = [
for (final assignment in assignments)
protocol.NoteTag(
noteId: _uuid(assignment.noteId),
tagId: _uuid(assignment.tagId),
),
];
if (rows.isEmpty) {
return;
}
await protocol.NoteTag.db.insert(_db, rows);
_sync.requestSync();
}
@override
Future<void> deleteNoteTags(Set<String> ids) async {
if (ids.isEmpty) {
return;
}
await protocol.NoteTag.db.deleteWhere(
_db,
where: (t) => t.id.inSet({for (final id in ids) _uuid(id)}),
);
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;

View file

@ -7,6 +7,10 @@ typedef TagRow = ({String id, ByteData payload});
/// assignment, never updated (ADR 0004, rule 2).
typedef CardTagRow = ({String id, String cardId, String tagId});
/// A note having a tag, as the device's database stores it: one row per
/// assignment, never updated, as for cards.
typedef NoteTagRow = ({String id, String noteId, String tagId});
/// The boundary to the tags in this person's local, synced database. Tests
/// replace it with a fake.
abstract interface class TagGateway() {
@ -16,13 +20,16 @@ abstract interface class TagGateway() {
/// Every card's tags on this device, unordered.
Future<List<CardTagRow>> cardTags();
/// Every note's tags on this device, unordered.
Future<List<NoteTagRow>> noteTags();
/// Stores a new tag and returns its id, a UUID v7 made on the device.
Future<String> insertTag(ByteData payload);
/// Replaces a tag's payload.
Future<void> updateTagPayload(String id, ByteData payload);
/// Deletes the tag [id] and every card's assignment of it.
/// Deletes the tag [id] and every card's and note's assignment of it.
Future<void> deleteTag(String id);
/// Gives each card in [assignments] its tag.
@ -33,6 +40,14 @@ abstract interface class TagGateway() {
/// Deletes the assignments [ids].
Future<void> deleteCardTags(Set<String> ids);
/// Gives each note in [assignments] its tag.
Future<void> insertNoteTags(
Iterable<({String noteId, String tagId})> assignments,
);
/// Deletes the note assignments [ids].
Future<void> deleteNoteTags(Set<String> ids);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -9,25 +9,35 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
/// A deleted tag, as much of it as undoing the deletion brings back.
typedef DeletedTag = ({String name, String colorName, Set<String> cardIds});
typedef DeletedTag = ({
String name,
String colorName,
Set<String> cardIds,
Set<String> noteIds,
});
/// The person's tags and which cards have them: read from this device,
/// changed offline, and synced with their other devices in the background.
/// The person's tags and which cards and notes have them: read from this
/// device, changed offline, and synced with their other devices in the
/// background. Cards and notes share the tags.
///
/// A tag is its name (ADR 0004, rule 2). Two tags of one name, made on two
/// devices offline or by renaming one, fold into the one with the smallest
/// id: it gets the other's cards, and the other is deleted. Every device picks
/// the same one, so they all end up alike.
/// id: it gets the other's cards and notes, and the other is deleted. Every
/// device picks the same one, so they all end up alike.
class TagRepository(
final TagGateway _gateway, {
/// Fires when cards change; a deleted card takes its tags along.
Stream<void> cardChanges = const Stream.empty(),
/// Fires when notes change; a deleted note takes its tags along.
Stream<void> noteChanges = const Stream.empty(),
}) {
this {
_subscriptions = [
_gateway.remoteChanges.listen((_) => unawaited(_refresh())),
cardChanges.listen((_) => unawaited(_refresh())),
noteChanges.listen((_) => unawaited(_refresh())),
];
}
@ -110,6 +120,30 @@ class TagRepository(
await _refresh();
}
/// Gives the note [noteId] exactly the tags [tagIds]: the missing ones
/// are added, the others taken away. Tags gone in the meantime are left
/// out.
Future<void> setTagsOfNote(String noteId, Set<String> tagIds) async {
await _serially(() async {
final tagging = await _read();
final rows = [
for (final row in await _gateway.noteTags())
if (row.noteId == noteId) row,
];
final has = {for (final row in rows) row.tagId};
await _gateway.deleteNoteTags({
for (final row in rows)
if (!tagIds.contains(row.tagId)) row.id,
});
await _gateway.insertNoteTags([
for (final tagId in tagIds)
if (!has.contains(tagId) && tagging.byId(tagId) != null)
(noteId: noteId, tagId: tagId),
]);
});
await _refresh();
}
/// Gives cards more tags at once: each card in [tagIdsByCard] the tags
/// listed for it that it hasn't got yet. Tags gone in the meantime are left
/// out.
@ -129,7 +163,8 @@ class TagRepository(
await _refresh();
}
/// Deletes the tag [id] from every card and then itself; returns what
/// Deletes the tag [id] from every card and note and then itself; returns
/// what
/// [restore] needs to undo that. Its name is blanked first: the sync
/// engine keeps deleted rows until the whole space is deleted (ADR 0002,
/// rule 2).
@ -138,30 +173,43 @@ class TagRepository(
final tagging = await _read();
final tag = tagging.byId(id);
if (tag == null) {
return (name: '', colorName: defaultTagColorName, cardIds: <String>{});
return (
name: '',
colorName: defaultTagColorName,
cardIds: <String>{},
noteIds: <String>{},
);
}
await _deleteTag(id);
return (
name: tag.name,
colorName: tag.colorName,
cardIds: tagging.cardsWith(id),
noteIds: tagging.notesWith(id),
);
});
await _refresh();
return deleted;
}
/// Undoes deleting a tag: a tag of its name and colour, on its cards.
/// Undoes deleting a tag: a tag of its name and colour, on its cards and
/// notes.
Future<void> restore(DeletedTag deleted) async {
if (deleted.name.isEmpty) {
return;
}
await _serially(() async {
final id = await _idOf(deleted.name, deleted.colorName);
final has = (await _read()).cardsWith(id);
final tagging = await _read();
final hasCards = tagging.cardsWith(id);
await _gateway.insertCardTags([
for (final cardId in deleted.cardIds)
if (!has.contains(cardId)) (cardId: cardId, tagId: id),
if (!hasCards.contains(cardId)) (cardId: cardId, tagId: id),
]);
final hasNotes = tagging.notesWith(id);
await _gateway.insertNoteTags([
for (final noteId in deleted.noteIds)
if (!hasNotes.contains(noteId)) (noteId: noteId, tagId: id),
]);
});
await _refresh();
@ -202,7 +250,8 @@ class TagRepository(
Future<Tagging> _read() async {
final tags = [for (final row in await _gateway.tags()) ?_decode(row)];
final rows = await _gateway.cardTags();
if (await _fold(tags, rows)) {
final noteRows = await _gateway.noteTags();
if (await _fold(tags, rows, noteRows)) {
return await _read();
}
final known = {for (final tag in tags) tag.id};
@ -212,15 +261,26 @@ class TagRepository(
(tagIdsByCard[row.cardId] ??= {}).add(row.tagId);
}
}
final tagIdsByNote = <String, Set<String>>{};
for (final row in noteRows) {
if (known.contains(row.tagId)) {
(tagIdsByNote[row.noteId] ??= {}).add(row.tagId);
}
}
return Tagging(
tags..sort((a, b) => a.name.compareTo(b.name)),
tagIdsByCard,
tagIdsByNote,
);
}
/// Folds every name's tags into the one with the smallest id; returns
/// whether there were any to fold.
Future<bool> _fold(List<Tag> tags, List<CardTagRow> rows) async {
Future<bool> _fold(
List<Tag> tags,
List<CardTagRow> rows,
List<NoteTagRow> noteRows,
) async {
final byName = <String, List<Tag>>{};
for (final tag in tags) {
(byName[tag.name] ??= []).add(tag);
@ -245,6 +305,17 @@ class TagRepository(
})
if (!kept.contains(cardId)) (cardId: cardId, tagId: keeper.id),
]);
final keptNotes = {
for (final row in noteRows)
if (row.tagId == keeper.id) row.noteId,
};
await _gateway.insertNoteTags([
for (final noteId in {
for (final row in noteRows)
if (others.contains(row.tagId)) row.noteId,
})
if (!keptNotes.contains(noteId)) (noteId: noteId, tagId: keeper.id),
]);
for (final other in others) {
await _deleteTag(other);
}

View file

@ -1,15 +1,18 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// The person's tagging: their tags and the cards that have them, as read at
/// one moment.
/// The person's tagging: their tags and the cards and notes that have them,
/// as read at one moment.
final class const Tagging(
/// Every tag, by name.
final List<Tag> all,
/// The ids of the tags each card has, by the card's id.
final Map<String, Set<String>> _tagIdsByCard,
) with Equatable {
final Map<String, Set<String>> _tagIdsByCard, [
/// The ids of the tags each note has, by the note's id.
final Map<String, Set<String>> _tagIdsByNote = const {},
]) with Equatable {
/// No tags at all.
static const empty = Tagging([], {});
@ -24,13 +27,10 @@ final class const Tagging(
}
/// The tags the card [cardId] has, by name.
List<Tag> of(String cardId) {
final ids = _tagIdsByCard[cardId] ?? const {};
return [
for (final tag in all)
if (ids.contains(tag.id)) tag,
];
}
List<Tag> of(String cardId) => _tagsIn(_tagIdsByCard[cardId]);
/// The tags the note [noteId] has, by name.
List<Tag> ofNote(String noteId) => _tagsIn(_tagIdsByNote[noteId]);
/// The ids of the tags [refs] refer to: by id, or by name once the id is
/// gone. So a renamed tag is still found, and so is one folded into another
@ -45,6 +45,18 @@ final class const Tagging(
if (ids.contains(tagId)) card,
};
/// The notes that have the tag [tagId].
Set<String> notesWith(String tagId) => {
for (final MapEntry(key: note, value: ids) in _tagIdsByNote.entries)
if (ids.contains(tagId)) note,
};
/// The tags with the ids [ids], by name.
List<Tag> _tagsIn(Set<String>? ids) => [
for (final tag in all)
if (ids?.contains(tag.id) ?? false) tag,
];
@override
List<Object?> get props => [all, _tagIdsByCard];
List<Object?> get props => [all, _tagIdsByCard, _tagIdsByNote];
}

View file

@ -5,6 +5,8 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
@ -15,6 +17,7 @@ import 'package:material_ui/material_ui.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_review_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
@ -26,9 +29,11 @@ import '../../support/test_dependencies.dart';
PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
final studysets = StudysetRepository(FakeStudysetGateway());
final cards = CardRepository(FakeCardGateway());
final notes = NoteRepository(FakeNoteGateway());
return PersonDependencies(
studysets: studysets,
cards: cards,
notes: notes,
reviews: ReviewRepository(FakeReviewGateway()),
tags: TagRepository(FakeTagGateway()),
settings: SettingsRepository(FakeSettingsGateway()),
@ -37,7 +42,8 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
FakeAssetServerGateway(),
MemoryAssetFileCache(),
),
deletions: StudysetDeletions(studysets, cards),
deletions: StudysetDeletions(studysets, cards, notes),
noteDeletions: NoteDeletions(notes),
onDispose: onDispose,
);
}

View file

@ -54,6 +54,7 @@ Map<String, int> _fillSyncedTables(Database db) {
final studyset = _id();
final card = _id();
final tag = _id();
final note = _id();
final rows = <String, (String, List<Object?>)>{
'studyset': (
"INSERT INTO studyset (id, spaceId, payload) VALUES (?, 1, x'00')",
@ -91,6 +92,20 @@ Map<String, int> _fillSyncedTables(Database db) {
"VALUES (?, 1, 'ab', 'image/png', 1, x'00')",
[_id()],
),
'note': (
'INSERT INTO note (id, spaceId, studysetId, position) '
"VALUES (?, 1, ?, 'a0')",
[note, studyset],
),
'note_revision': (
'INSERT INTO note_revision (id, spaceId, noteId, parents, authorId, '
"payload) VALUES (?, 1, ?, '[]', ?, x'00')",
[_id(), note, _id()],
),
'note_tag': (
'INSERT INTO note_tag (id, spaceId, noteId, tagId) VALUES (?, 1, ?, ?)',
[_id(), note, tag],
),
};
final synced = {

View file

@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import '../../../support/eventually.dart';
import '../../../support/fake_note_gateway.dart';
void main() {
late FakeNoteGateway gateway;
late NoteRepository notes;
late NoteDeletions deletions;
setUp(() {
gateway = FakeNoteGateway();
notes = NoteRepository(gateway);
deletions = NoteDeletions(
notes,
undoWindow: const Duration(milliseconds: 50),
);
});
tearDown(() async {
await deletions.dispose();
await notes.dispose();
});
Future<String> note() async => (await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.plain('は')),
)).noteId;
test('a deletion waits for the time to undo, then blanks the note and '
'deletes it', () async {
final id = await note();
deletions.delete(id);
expect(deletions.pending, {id});
expect(gateway.deletions, isEmpty);
await eventually(() => deletions.pending.isEmpty);
expect(gateway.deletions, ['blank $id', 'delete $id']);
expect(await notes.notesOf('japanese'), isEmpty);
});
test('undoing in time keeps the note', () async {
final id = await note();
deletions
..delete(id)
..undo(id);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(deletions.pending, isEmpty);
expect(gateway.deletions, isEmpty);
expect(await notes.notesOf('japanese'), hasLength(1));
});
test('closing drops what still waits', () async {
final id = await note();
deletions.delete(id);
await deletions.dispose();
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(gateway.deletions, isEmpty);
deletions = NoteDeletions(notes);
});
}

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