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

View file

@ -9,6 +9,9 @@ import 'package:halkyon_learn_flutter/src/core/sync/sync_service.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/card/model/card.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/note/model/note.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';
@ -121,6 +124,9 @@ final class SyncDevice(
/// The app's cards on this device's database.
final CardRepository cards,
/// The app's notes on this device's database.
final NoteRepository notes,
/// The app's reviews on this device's database.
final ReviewRepository reviews,
@ -154,16 +160,21 @@ final class SyncDevice(
final cards = CardRepository(
SyncCardGateway(db, syncRequests, person.authUserId.uuid),
);
final notes = NoteRepository(
SyncNoteGateway(db, syncRequests, person.authUserId.uuid),
);
return SyncDevice(
client,
db,
syncRequests,
StudysetRepository(SyncStudysetGateway(db, syncRequests)),
cards,
notes,
ReviewRepository(SyncReviewGateway(db, syncRequests)),
TagRepository(
SyncTagGateway(db, syncRequests),
cardChanges: cards.changes,
noteChanges: notes.changes,
),
SettingsRepository(SyncSettingsGateway(db, syncRequests)),
AssetRepository(
@ -197,6 +208,16 @@ final class SyncDevice(
return null;
}
/// The note with [id] in [studysetId] on this device, or null.
Future<Note?> note(String studysetId, String id) async {
for (final note in await notes.notesOf(studysetId)) {
if (note.id == id) {
return note;
}
}
return null;
}
/// Closes everything the device opened.
Future<void> close() async {
await studysets.dispose();
@ -204,6 +225,7 @@ final class SyncDevice(
await settings.dispose();
await reviews.dispose();
await cards.dispose();
await notes.dispose();
await assets.dispose();
await _syncRequests.dispose();
await _db.close();