halkyon/halkyon_learn_flutter/test/support/fake_note_gateway.dart

149 lines
4.3 KiB
Dart
Raw Normal View History

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>
2026-10-02 21:53:56 +02:00
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
/// A [NoteGateway] in memory. Ids sort by creation, as UUID v7 ids do: notes
/// are `note-0001`, `note-0002`, …, revisions `nrev-0001`, …, each written
/// at the clock's time; [receiveRemoteRevision] plays a sync that brought
/// another device's edit.
class FakeNoteGateway() implements NoteGateway {
final _notes = <String, ({String studysetId, String position})>{};
final _revisions = <String, NoteRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every revision written on this device, in order.
final written = <NoteRevisionRow>[];
/// The deletion steps, in order: `blank <note id>`, `delete <note id>`.
final deletions = <String>[];
/// When set, storing a note or a revision fails with it.
Exception? failWrites;
/// The payload each revision holds now, by revision id.
Map<String, ByteData> get payloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
};
/// Stores a revision as if another device had written it and a sync had
/// brought it here.
void receiveRemoteRevision({
required String id,
required String noteId,
required Set<String> parents,
required ByteData payload,
}) {
_revisions[id] = (
id: id,
noteId: noteId,
parents: parents,
payload: payload,
writtenAt: clock.now().toUtc(),
);
_remoteChanges.add(null);
}
feat(app): notes in the studyset screen, written in the whole window - A Notes tab beside Flashcards lists a studyset's notes in order: the title ("Untitled note" without one) and when each was last edited ("Edited 5 minutes ago", by the calendar from yesterday on, the date after a week); Edit and Delete behind its options. Deleting asks, then offers Undo for a moment (`NoteDeletions`). With none, the tab says so. - "Create a note" in Add material opens a new note over the tabs; so does a note of the list. The note: a title, its tags once it exists, and its text at reading width; the app bar says how saving stands. - `NoteEditorBloc` saves after 5 seconds without typing, when the app goes to the background, and when the note is closed (the bloc saves as it closes, so leaving never waits, and the write outlives the screen). Each save builds on the revisions the editor started from; another device's change takes over a note with nothing unsaved, and merges with one that has. A note changed differently on two devices is read-only until the person keeps a version of each field in conflict. A new note is created by its first save, never when left blank; a note deleted elsewhere says it no longer exists. - The editor gains a format bar, docked below it while it has the focus (`RichContentEditor(formatBar: true)`): alignment, bold, italic, underline, superscript and subscript. It takes no focus, so the selection stays. Superscript and subscript are inline marks of their own, drawn by the font's raised and lowered glyphs in editors and read-only text alike. - The library's and the topics' counts include notes (a studyset with what is inside it, as for cards). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-02 22:30:53 +02:00
/// Deletes the note [noteId] as if another device had done it and a sync
/// had brought it here.
void receiveRemoteDeletion(String noteId) {
_notes.remove(noteId);
_remoteChanges.add(null);
}
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>
2026-10-02 21:53:56 +02:00
@override
Future<List<NoteRow>> notesOf(String studysetId) async => [
for (final MapEntry(key: id, value: note) in _notes.entries)
if (note.studysetId == studysetId) (id: id, position: note.position),
];
@override
Future<List<NoteStampRow>> allNotes() async => [
for (final MapEntry(key: id, value: note) in _notes.entries)
(id: id, studysetId: note.studysetId),
];
@override
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds) async => [
for (final revision in _revisions.values)
if (noteIds.contains(revision.noteId)) revision,
];
@override
Future<({String noteId, String revisionId})> insertNote({
required String studysetId,
required String position,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final id = _newId('note');
_notes[id] = (studysetId: studysetId, position: position);
final revisionId = await insertRevision(
noteId: id,
parents: const {},
payload: payload,
);
return (noteId: id, revisionId: revisionId);
}
@override
Future<String> insertRevision({
required String noteId,
required Set<String> parents,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final revision = (
id: _newId('nrev'),
noteId: noteId,
parents: parents,
payload: payload,
writtenAt: clock.now().toUtc(),
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
@override
Future<void> updatePosition(String noteId, String position) async {
if (_notes[noteId] case final note?) {
_notes[noteId] = (studysetId: note.studysetId, position: position);
}
}
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>
2026-10-02 21:53:56 +02:00
@override
Future<void> blankRevisions(String noteId) async {
deletions.add('blank $noteId');
for (final revision in [..._revisions.values]) {
if (revision.noteId == noteId) {
_revisions[revision.id] = (
id: revision.id,
noteId: noteId,
parents: revision.parents,
payload: redactedPayload,
writtenAt: revision.writtenAt,
);
}
}
}
@override
Future<void> deleteNote(String noteId) async {
deletions.add('delete $noteId');
_notes.remove(noteId);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String kind) =>
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
}