feat(app): flashcards on append-only revisions

A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side.

CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews.

Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 14:41:44 +02:00
commit 66886ef383
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
33 changed files with 2392 additions and 142 deletions

View file

@ -34,6 +34,8 @@ Only when the server cannot be started at all, fall back to the CLI in the serve
Tests need no Docker. `config/test.yaml` sets `database.dataPath`, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run `dart test` in the server package.
Two tooling traps: `dart fix --apply` moves `flutter_driver` into the app's `dependencies` (as `any`) on every run, so restore it as a dev dependency (`sdk: flutter`) afterwards; and never `dart format` generated code, only the hand-written paths, as `tool/check.sh` does.
Checklist after doing changes, in this order:
- `dart format` (CLI) on the files you changed
@ -79,7 +81,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
- **Pushed screens live above that subtree** (the `Navigator` belongs to `MaterialApp`). A screen's static `route(context, …)` reads what it needs from `PersonScope.of(context)` when it is pushed and hands it to the screen's bloc, and `SessionGate` closes every pushed screen when the person changes.
- **Pushed screens live above that subtree** (the `Navigator` belongs to `MaterialApp`). A screen's static `route(context, …)` reads what it needs from `PersonScope.of(context)` when it is pushed and hands it to the screen's bloc, and `SessionGate` closes every pushed screen when the person changes. A screen pushed from a pushed screen gets the repositories handed along as `route()` parameters (e.g. `CardScreen.route(cards, …)`).
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
@ -96,6 +99,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead.
- **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`.
- **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason.
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a logged refusal) is checked with SQL against the development database. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
- **Widget tests of the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path).
@ -111,8 +115,10 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded in one place per feature (e.g. `studyset_payload.dart`). Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001, rule 2; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides, after reading the sync package's changelog.

View file

@ -49,6 +49,27 @@ final class RichContent(
@override
List<Object?> get props => [formatVersion, editorJson];
/// The text without its formatting, one line per block; for previews.
String get plainText {
final blocks = <String>[];
void visit(Object? node) {
if (node case {'data': {'delta': final List<Object?> delta}}) {
blocks.add(
[
for (final operation in delta)
if (operation case {'insert': final String text}) text,
].join(),
);
}
if (node case {'children': final List<Object?> children}) {
children.forEach(visit);
}
}
visit(editorJson['document']);
return blocks.where((block) => block.isNotEmpty).join('\n');
}
/// The JSON stored in the content payload.
Map<String, Object?> toJson() => {
'formatVersion': formatVersion,

View file

@ -7,6 +7,8 @@ import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.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/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:path/path.dart' as p;
@ -82,11 +84,16 @@ Future<PersonDependencies> _openPerson(Client client, SignedIn person) async {
),
)..start();
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
final cards = CardRepository(
SyncCardGateway(db, sync, person.authUserId),
);
return PersonDependencies(
studysets: studysets,
cards: cards,
onDispose: () async {
await studysets.dispose();
await cards.dispose();
await sync.dispose();
await db.close();
},

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
/// Everything that belongs to one signed-in person: their local database, its
@ -8,6 +9,9 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_reposit
class PersonDependencies({
/// The person's studysets.
required final StudysetRepository studysets,
/// The person's flashcards.
required final CardRepository cards,
final Future<void> Function()? _onDispose,
}) {
/// Closes what was opened for the person.

View file

@ -0,0 +1,142 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
/// Creates a card, or edits one: keeps the changes until they are saved, has
/// the person choose a version for every side devices changed differently,
/// and deletes on request.
class CardEditBloc(
final CardRepository _cards,
final String _studysetId,
Card? card,
) extends Bloc<CardEditEvent, CardEditState> {
this
: super(
CardEditState(
content: card?.content ?? CardContent.empty(),
card: card,
),
) {
on<CardEditEventSideChanged>(
(event, emit) => emit(_withSide(event.side, event.content)),
);
on<CardEditEventVersionChosen>(
(event, emit) => emit(_withSide(event.side, event.version)),
);
on<CardEditEventSaveRequested>(_onSaveRequested);
on<CardEditEventDeleteRequested>(_onDeleteRequested);
}
CardEditState _withSide(CardSide side, RichContent content) => state.copyWith(
content: state.content.withSide(side, content),
resolvedSides: {...state.resolvedSides, side},
);
Future<void> _onSaveRequested(
CardEditEventSaveRequested event,
Emitter<CardEditState> emit,
) async {
if (!state.canSave) {
return;
}
if (state.card case final card?) {
await _cards.save(card, state.content);
} else {
await _cards.create(_studysetId, state.content);
}
emit(state.copyWith(isDone: true));
}
Future<void> _onDeleteRequested(
CardEditEventDeleteRequested event,
Emitter<CardEditState> emit,
) async {
if (state.card case final card?) {
await _cards.delete(card);
}
emit(state.copyWith(isDone: true));
}
}
/// What happens while a card is edited.
sealed class const CardEditEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// [side] was edited to [content].
final class const CardEditEventSideChanged(
/// The side edited.
final CardSide side,
/// Its new content.
final RichContent content,
) extends CardEditEvent {
@override
List<Object?> get props => [side, content];
}
/// For a side changed differently on two devices, the person keeps [version].
final class const CardEditEventVersionChosen(
/// The conflicted side.
final CardSide side,
/// The version to keep.
final RichContent version,
) extends CardEditEvent {
@override
List<Object?> get props => [side, version];
}
/// Store the card.
final class const CardEditEventSaveRequested() extends CardEditEvent;
/// Delete the card; the person has confirmed it.
final class const CardEditEventDeleteRequested() extends CardEditEvent;
/// A card being created or edited.
final class const CardEditState({
/// The content with the changes made so far.
required final CardContent content,
/// The card as stored; null for a new card.
final Card? card,
/// The conflicted sides the person has settled.
final Set<CardSide> resolvedSides = const {},
/// Saved or deleted; the screen closes then.
final bool isDone = false,
}) with Equatable {
/// The sides devices changed differently that still need a choice.
Set<CardSide> get unresolvedSides => {
...?card?.conflicts.keys,
}.difference(resolvedSides);
/// Whether saving would store something: every conflicted side settled,
/// and a new card with content or a stored one changed or in conflict.
bool get canSave =>
unresolvedSides.isEmpty &&
switch (card) {
null => content != CardContent.empty(),
final card => content != card.content || card.hasConflict,
};
/// This state with some fields replaced.
CardEditState copyWith({
CardContent? content,
Set<CardSide>? resolvedSides,
bool? isDone,
}) => CardEditState(
content: content ?? this.content,
card: card,
resolvedSides: resolvedSides ?? this.resolvedSides,
isDone: isDone ?? this.isDone,
);
@override
List<Object?> get props => [content, card, resolvedSides, isDone];
}

View file

@ -0,0 +1,70 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
/// Shows a studyset's cards in order and moves them; the list follows every
/// change, local or synced.
class CardListBloc(final CardRepository _cards, final String _studysetId)
extends Bloc<CardListEvent, CardListState> {
this : super(const CardListStateLoading()) {
on<CardListEventStarted>(_onStarted);
on<CardListEventMoved>(_onMoved);
}
Future<void> _onStarted(
CardListEventStarted event,
Emitter<CardListState> emit,
) => emit.forEach(
_cards.watch(_studysetId),
onData: CardListStateLoaded.new,
onError: (_, _) => const CardListStateFailed(),
);
Future<void> _onMoved(
CardListEventMoved event,
Emitter<CardListState> emit,
) => _cards.move(_studysetId, event.card, toIndex: event.toIndex);
}
/// What happens to the list of cards.
sealed class const CardListEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The list opens: follow the studyset's cards.
final class const CardListEventStarted() extends CardListEvent;
/// Move [card] to [toIndex].
final class const CardListEventMoved(
/// The card to move.
final Card card, {
/// Where it goes, counted in the list without it.
required final int toIndex,
}) extends CardListEvent {
@override
List<Object?> get props => [card, toIndex];
}
/// A studyset's cards, as the list shows them.
sealed class const CardListState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Reading the cards.
final class const CardListStateLoading() extends CardListState;
/// The cards couldn't be read; the next change tries again.
final class const CardListStateFailed() extends CardListState;
/// The cards, in order.
final class const CardListStateLoaded(
/// The cards of the studyset on this device.
final List<Card> cards,
) extends CardListState {
@override
List<Object?> get props => [cards];
}

View file

@ -0,0 +1,48 @@
import 'dart:typed_data';
/// A card's row as the device's database stores it.
typedef CardRow = ({String id, String position});
/// A card revision's row as the device's database stores it.
typedef CardRevisionRow = ({
String id,
String cardId,
Set<String> parents,
ByteData payload,
});
/// The boundary to the cards in this person's local, synced database. Tests
/// replace it with a fake.
abstract interface class CardGateway() {
/// The cards of the studyset [studysetId] on this device, unordered.
Future<List<CardRow>> cardsOf(String studysetId);
/// The revisions of the cards [cardIds], unordered.
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds);
/// Stores a new card with its first revision; returns the card's id.
Future<String> insertCard({
required String studysetId,
required String position,
required ByteData payload,
});
/// Appends a revision to the card [cardId] that replaces [parents].
Future<void> insertRevision({
required String cardId,
required Set<String> parents,
required ByteData payload,
});
/// Moves the card [cardId] to [position]; the only column that changes.
Future<void> updatePosition(String cardId, String position);
/// Blanks the payload of every revision of the card [cardId].
Future<void> blankRevisions(String cardId);
/// Deletes the card [cardId] and its revisions.
Future<void> deleteCard(String cardId);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,36 @@
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/card/model/card_content.dart';
// A card revision's content as its synced row stores it (see
// core/content/payload).
/// The schema this app writes.
const cardSchemaVersion = 1;
/// Encodes a card's content into a revision's payload.
ByteData encodeCardPayload(CardContent content) =>
encodePayload(cardSchemaVersion, {
'front': content.front.toJson(),
'back': content.back.toJson(),
});
/// Decodes a 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.
CardContent decodeCardPayload(ByteData payload) {
final json = decodePayload(payload, newestSchema: cardSchemaVersion);
if (json case {
'front': final Map<String, Object?> front,
'back': final Map<String, Object?> back,
}) {
return CardContent(
front: RichContent.fromJson(front),
back: RichContent.fromJson(back),
);
}
throw const FormatException('Not a card payload.');
}

View file

@ -0,0 +1,183 @@
import 'dart:async';
import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart';
/// The person's flashcards: content kept as append-only revisions (ADR 0001,
/// rule 2), merged per side when devices edited offline, ordered by a
/// fractional index (decided 2026-09-30).
class CardRepository(final CardGateway _gateway) {
this {
_remoteChanges = _gateway.remoteChanges.listen((_) => _changes.add(null));
}
final _changes = StreamController<void>.broadcast();
late final StreamSubscription<void> _remoteChanges;
/// The cards 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<Card>> 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 cards = await cardsOf(studysetId);
if (!cancelled) {
listener.add(cards);
}
} on Object catch (error, st) {
log('Reading cards failed', name: 'Cards', error: error);
if (!cancelled) {
listener.addError(error, st);
}
}
});
final subscription = _changes.stream.listen((_) => read());
listener.onCancel = () {
cancelled = true;
return subscription.cancel();
};
read();
});
/// The cards of [studysetId] on this device right now, in order, read
/// once; [watch] follows every change.
Future<List<Card>> cardsOf(String studysetId) async {
final rows = await _gateway.cardsOf(studysetId);
final revisionsByCard = <String, List<CardRevision>>{};
for (final row in await _gateway.revisionsOf({
for (final card in rows) card.id,
})) {
if (_decode(row) case final revision?) {
(revisionsByCard[row.cardId] ??= []).add(revision);
}
}
return [
for (final row in rows)
if (mergeRevisions(revisionsByCard[row.id] ?? const [])
case final merged?)
Card(
id: row.id,
position: row.position,
content: merged.content,
heads: merged.heads,
conflicts: merged.conflicts,
),
]..sort(
(a, b) => switch (a.position.compareTo(b.position)) {
0 => a.id.compareTo(b.id),
final order => order,
},
);
}
/// Creates a card with [content] at the end of [studysetId]; returns its
/// id.
Future<String> create(String studysetId, CardContent content) async {
String? last;
for (final card in await _gateway.cardsOf(studysetId)) {
if (last == null || card.position.compareTo(last) > 0) {
last = card.position;
}
}
final id = await _gateway.insertCard(
studysetId: studysetId,
position: FractionalIndexing.generateKeyBetween(last, null),
payload: encodeCardPayload(content),
);
_changes.add(null);
return id;
}
/// Stores [content] as the next revision of [card], replacing all its
/// current heads, which resolves a conflict. Nothing unchanged is written.
Future<void> save(Card card, CardContent content) async {
if (content == card.content && !card.hasConflict) {
return;
}
await _gateway.insertRevision(
cardId: card.id,
parents: card.heads,
payload: encodeCardPayload(content),
);
_changes.add(null);
}
/// Moves [card] to [toIndex] among the cards of [studysetId].
Future<void> move(
String studysetId,
Card card, {
required int toIndex,
}) async {
final others = [
for (final other in await cardsOf(studysetId))
if (other.id != card.id) other,
];
final index = toIndex.clamp(0, others.length);
final lower = index > 0 ? others[index - 1].position : null;
// Two devices appending at once can pick the same position; the key then
// goes after every card sharing the lower one.
String? upper;
for (final other in others.skip(index)) {
if (lower == null || other.position.compareTo(lower) > 0) {
upper = other.position;
break;
}
}
await _gateway.updatePosition(
card.id,
FractionalIndexing.generateKeyBetween(lower, upper),
);
_changes.add(null);
}
/// Deletes [card]. Its revisions are blanked first: the sync engine keeps
/// deleted rows until the whole space is deleted (ADR 0002, rule 2).
Future<void> delete(Card card) async {
await _gateway.blankRevisions(card.id);
await _gateway.deleteCard(card.id);
_changes.add(null);
}
/// Deletes every card of [studysetId] as [delete] does, before the
/// studyset itself goes.
Future<void> deleteAllOf(String studysetId) async {
for (final card in await _gateway.cardsOf(studysetId)) {
await _gateway.blankRevisions(card.id);
await _gateway.deleteCard(card.id);
}
_changes.add(null);
}
/// Stops following remote changes.
Future<void> dispose() async {
await _remoteChanges.cancel();
await _changes.close();
}
/// The revision in [row], or null for one this app can't read: written by
/// a newer app version, or blanked.
CardRevision? _decode(CardRevisionRow row) {
try {
return CardRevision(
id: row.id,
parents: row.parents,
content: decodeCardPayload(row.payload),
);
} on FormatException catch (error) {
log('Skipped card revision ${row.id}: ${error.message}', name: 'Cards');
return null;
}
}
}

View file

@ -0,0 +1,140 @@
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/feature/card/data/card_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [CardGateway] on the person's local database, which the sync engine keeps
/// in step with their other devices. Revisions record [_authorId] as their
/// author.
class SyncCardGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
final String _authorId,
) implements CardGateway {
@override
Future<List<CardRow>> cardsOf(String studysetId) async => [
for (final card in await protocol.Card.db.find(
_db,
where: (t) =>
t.studysetId.equals(protocol.UuidValue.withValidation(studysetId)),
))
(id: card.id!.uuid, position: card.position),
];
@override
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async {
if (cardIds.isEmpty) {
return const [];
}
return [
for (final revision in await protocol.CardRevision.db.find(
_db,
where: (t) => t.cardId.inSet({
for (final id in cardIds) protocol.UuidValue.withValidation(id),
}),
))
(
id: revision.id!.uuid,
cardId: revision.cardId.uuid,
parents: {for (final parent in revision.parents) parent.uuid},
payload: revision.payload,
),
];
}
@override
Future<String> insertCard({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final card = await protocol.Card.db.insertRow(
_db,
protocol.Card(
studysetId: protocol.UuidValue.withValidation(studysetId),
position: position,
),
);
await insertRevision(
cardId: card.id!.uuid,
parents: const {},
payload: payload,
);
return card.id!.uuid;
}
@override
Future<void> insertRevision({
required String cardId,
required Set<String> parents,
required ByteData payload,
}) async {
await protocol.CardRevision.db.insertRow(
_db,
protocol.CardRevision(
cardId: protocol.UuidValue.withValidation(cardId),
parents: [
for (final parent in parents)
protocol.UuidValue.withValidation(parent),
],
authorId: protocol.UuidValue.withValidation(_authorId),
payload: payload,
),
);
_sync.requestSync();
}
@override
Future<void> updatePosition(String cardId, String position) async {
final card = await protocol.Card.db.findById(
_db,
protocol.UuidValue.withValidation(cardId),
);
if (card == null) {
return;
}
// Only the named column changes (ADR 0001, rule 1).
await protocol.Card.db.updateRow(
_db,
card.copyWith(position: position),
columns: (t) => [t.position],
);
_sync.requestSync();
}
@override
Future<void> blankRevisions(String cardId) async {
final revisions = await protocol.CardRevision.db.find(
_db,
where: (t) => t.cardId.equals(protocol.UuidValue.withValidation(cardId)),
);
// A column-scoped update, which syncs like any other edit (ADR 0002,
// rule 2).
await protocol.CardRevision.db.update(
_db,
[
for (final revision in revisions)
revision.copyWith(payload: redactedPayload),
],
columns: (t) => [t.payload],
);
_sync.requestSync();
}
@override
Future<void> deleteCard(String cardId) async {
final id = protocol.UuidValue.withValidation(cardId);
await protocol.CardRevision.db.deleteWhere(
_db,
where: (t) => t.cardId.equals(id),
);
await protocol.Card.db.deleteWhere(_db, where: (t) => t.id.equals(id));
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,29 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
/// A flashcard as the app shows it: its revisions merged (`mergeRevisions`).
final class const Card({
/// A UUID v7, made on the device that created it.
required final String id,
/// Where the card stands in its studyset: a fractional index.
required final String position,
/// The current content. For a side in [conflicts], the newest version shows
/// until the person chooses.
required final CardContent content,
/// The revisions the next save replaces: all current heads.
required final Set<String> heads,
/// The sides changed differently on different devices, with every version,
/// oldest first.
final Map<CardSide, List<RichContent>> conflicts = const {},
}) with Equatable {
/// Whether devices changed a side differently and the person has to choose.
bool get hasConflict => conflicts.isNotEmpty;
@override
List<Object?> get props => [id, position, content, heads, conflicts];
}

View file

@ -0,0 +1,39 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// The sides of a card, which merge separately (decided 2026-09-30).
enum CardSide() {
/// The prompt.
front,
/// The answer to recall.
back,
}
/// What a card shows: its [front], and the [back] to recall.
final class const CardContent({
/// The prompt.
required final RichContent front,
/// The answer to recall.
required final RichContent back,
}) with Equatable {
/// A card with nothing on either side.
factory empty() =>
CardContent(front: RichContent.empty(), back: RichContent.empty());
/// The content of [side].
RichContent operator [](CardSide side) => switch (side) {
CardSide.front => front,
CardSide.back => back,
};
/// This content with [side] replaced by [content].
CardContent withSide(CardSide side, RichContent content) => switch (side) {
CardSide.front => CardContent(front: content, back: back),
CardSide.back => CardContent(front: front, back: content),
};
@override
List<Object?> get props => [front, back];
}

View file

@ -0,0 +1,107 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart';
/// A card's content, merged from its revisions by [mergeRevisions].
final class const MergedCard({
/// The current content. For a side in [conflicts], the newest version shows
/// until the person chooses.
required final CardContent content,
/// The revisions the next edit replaces: all current heads.
required final Set<String> heads,
/// The sides changed differently on different devices, with every version,
/// oldest first.
final Map<CardSide, List<RichContent>> conflicts = const {},
}) with Equatable {
@override
List<Object?> get props => [content, heads, conflicts];
}
/// Merges a card's [revisions] (decided 2026-09-30), or null without any.
///
/// The heads are the revisions no other revision replaces. One head is the
/// current version. Several heads, from edits on different devices while
/// offline, merge side by side against their newest common ancestor: a side
/// only one of them changed takes that change, a side they changed
/// differently is a conflict. Nothing is merged silently inside a side.
/// Revisions whose parents haven't arrived yet count as they are.
MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
final byId = {for (final revision in revisions) revision.id: revision};
if (byId.isEmpty) {
return null;
}
final replaced = {for (final revision in byId.values) ...revision.parents};
// UUID v7 ids sort by creation (ADR 0001, rule 7): the newest head is last.
final heads = [
for (final revision in byId.values)
if (!replaced.contains(revision.id)) revision,
]..sort((a, b) => a.id.compareTo(b.id));
if (heads.length == 1) {
return MergedCard(content: heads.single.content, heads: {heads.single.id});
}
final base = _commonAncestor(heads, byId)?.content;
var content = heads.last.content;
final conflicts = <CardSide, List<RichContent>>{};
for (final side in CardSide.values) {
// What the heads changed this side to, oldest first, without repeats.
final changed = <RichContent>[];
for (final head in heads) {
final version = head.content[side];
if (version != base?[side] && !changed.contains(version)) {
changed.add(version);
}
}
switch (changed) {
case []:
// Unchanged everywhere; the newest head holds it already.
break;
case [final only]:
content = content.withSide(side, only);
default:
conflicts[side] = changed;
content = content.withSide(side, changed.last);
}
}
return MergedCard(
content: content,
heads: {for (final head in heads) head.id},
conflicts: conflicts,
);
}
/// The newest revision all [heads] descend from, or null if they share none.
///
/// Of the common ancestors, only those no other common ancestor descends from
/// qualify; of those (possible after criss-cross merges), the newest.
CardRevision? _commonAncestor(
List<CardRevision> heads,
Map<String, CardRevision> byId,
) {
Set<String> lineage(String id) {
final seen = <String>{};
final pending = [id];
while (pending.isNotEmpty) {
final next = pending.removeLast();
if (byId.containsKey(next) && seen.add(next)) {
pending.addAll(byId[next]!.parents);
}
}
return seen;
}
final common = heads
.map((head) => lineage(head.id))
.reduce((a, b) => a.intersection(b));
final nearest = [
for (final candidate in common)
if (!common.any(
(other) => other != candidate && lineage(other).contains(candidate),
))
candidate,
]..sort();
return nearest.isEmpty ? null : byId[nearest.last];
}

View file

@ -0,0 +1,20 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
/// One version of a card's content, never changed once written (ADR 0001,
/// rule 2): editing appends a revision whose [parents] are the versions it
/// replaces.
final class const CardRevision({
/// A UUID v7, made on the device that wrote it; ids sort by creation.
required final String id,
/// The revisions this one replaces: none for a card's first, several when
/// it resolves a conflict.
required final Set<String> parents,
/// The card's content as of this revision.
required final CardContent content,
}) with Equatable {
@override
List<Object?> get props => [id, parents, content];
}

View file

@ -0,0 +1,93 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_screen.dart';
// Material's Card widget would clash with the domain's Card.
import 'package:material_ui/material_ui.dart' hide Card;
/// A studyset's cards: in order, draggable, each opening its editor; and a
/// way to add one. Lives inside a scrolling screen.
class const CardList({
/// The person's cards; the screens this list opens need them too.
required final CardRepository cards,
/// The studyset whose cards these are.
required final String studysetId,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) =>
CardListBloc(cards, studysetId)..add(const CardListEventStarted()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'Cards',
style: Theme.of(context).textTheme.titleSmall,
),
),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add card'),
onPressed: () => Navigator.of(
context,
).push(CardScreen.route(cards, studysetId, null)),
),
],
),
BlocBuilder<CardListBloc, CardListState>(
builder: (context, state) => switch (state) {
CardListStateLoading() => const SizedBox.shrink(),
CardListStateFailed() => const Text("Couldn't read the cards."),
CardListStateLoaded(cards: []) => const Text('No cards yet.'),
CardListStateLoaded(cards: final list) => _Cards(
cards,
studysetId,
list,
),
},
),
],
),
);
}
class const _Cards(
final CardRepository repository,
final String studysetId,
final List<Card> cards,
) extends StatelessWidget {
@override
Widget build(BuildContext context) => ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
onReorderItem: (from, to) => context.read<CardListBloc>().add(
CardListEventMoved(cards[from], toIndex: to),
),
children: [
for (final card in cards)
ListTile(
key: ValueKey(card.id),
title: Text(_preview(card.content.front.plainText)),
subtitle: Text(_preview(card.content.back.plainText)),
leading: card.hasConflict
? const Tooltip(
message: 'Changed on two devices',
child: Icon(Icons.call_split),
)
: null,
onTap: () => Navigator.of(
context,
).push(CardScreen.route(repository, studysetId, card)),
),
],
);
}
/// The first line of [text], or a dash for nothing.
String _preview(String text) => text.isEmpty ? '–' : text.split('\n').first;

View file

@ -0,0 +1,165 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
// Material's Card widget would clash with the domain's Card.
import 'package:material_ui/material_ui.dart' hide Card;
/// Creates a card, or edits one: both sides, saved on request. A side that
/// devices changed differently shows every version to choose from first;
/// deleting asks.
class const CardScreen({
/// The card as the screen opens; null for a new card.
required final Card? card,
super.key,
}) extends StatelessWidget {
/// The route to [card]'s screen, or to a new card's in [studysetId]. It is
/// pushed from screens above the person's scope, so the person's [cards]
/// are handed over.
static Route<void> route(
CardRepository cards,
String studysetId,
Card? card,
) => MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => CardEditBloc(cards, studysetId, card),
child: CardScreen(card: card),
),
);
@override
Widget build(BuildContext context) =>
BlocListener<CardEditBloc, CardEditState>(
listenWhen: (previous, current) => current.isDone,
listener: (context, state) => Navigator.of(context).pop(),
child: Scaffold(
appBar: AppBar(
title: Text(card == null ? 'New card' : 'Card'),
actions: [
BlocSelector<CardEditBloc, CardEditState, bool>(
selector: (state) => state.canSave,
builder: (context, canSave) => IconButton(
icon: const Icon(Icons.save_outlined),
tooltip: 'Save',
onPressed: canSave
? () => context.read<CardEditBloc>().add(
const CardEditEventSaveRequested(),
)
: null,
),
),
if (card != null)
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Delete',
onPressed: () async {
final bloc = context.read<CardEditBloc>();
if (await _confirmDelete(context)) {
bloc.add(const CardEditEventDeleteRequested());
}
},
),
],
),
body: BlocBuilder<CardEditBloc, CardEditState>(
builder: (context, state) => ListView(
padding: const EdgeInsets.all(16),
children: [
if (state.unresolvedSides.isNotEmpty) ...[
const Text(
'This card was changed differently on two devices. '
'Choose the version to keep.',
),
const SizedBox(height: 16),
],
for (final side in CardSide.values) ...[
Text(
switch (side) {
CardSide.front => 'Front',
CardSide.back => 'Back',
},
style: Theme.of(context).textTheme.titleSmall,
),
if (state.unresolvedSides.contains(side))
for (final version in state.card!.conflicts[side]!)
_Version(side: side, version: version)
else
SizedBox(
height: 200,
child: RichContentEditor(
key: ValueKey(side),
initialContent: state.content[side],
onChanged: (content) => context
.read<CardEditBloc>()
.add(CardEditEventSideChanged(side, content)),
),
),
const SizedBox(height: 24),
],
],
),
),
),
);
}
/// One version of a side devices changed differently, and keeping it.
class const _Version({
required final CardSide side,
required final RichContent version,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 8),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RichContentView(content: version),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => context.read<CardEditBloc>().add(
CardEditEventVersionChosen(side, version),
),
child: const Text('Keep this'),
),
),
],
),
),
),
);
}
/// Asks before deleting a card.
Future<bool> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete this card?'),
content: const Text('It is deleted on all your devices.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
),
);
return confirmed ?? false;
}

View file

@ -1,6 +1,7 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
@ -54,8 +55,11 @@ final class const StudysetEditState({
/// Edits one studyset: keeps the changes until they are saved, and deletes it
/// on request.
class StudysetEditBloc(final StudysetRepository _studysets, Studyset studyset)
extends Bloc<StudysetEditEvent, StudysetEditState> {
class StudysetEditBloc(
final StudysetRepository _studysets,
final CardRepository _cards,
Studyset studyset,
) extends Bloc<StudysetEditEvent, StudysetEditState> {
this : super(StudysetEditState(studyset: studyset, saved: studyset)) {
on<StudysetEditEventTitleChanged>(
(event, emit) => emit(
@ -90,6 +94,9 @@ class StudysetEditBloc(final StudysetRepository _studysets, Studyset studyset)
StudysetEditEventDeleteRequested event,
Emitter<StudysetEditState> emit,
) async {
// Its cards first, each blanked, so no card content outlives the
// studyset (ADR 0002, rule 2).
await _cards.deleteAllOf(state.studyset.id);
await _studysets.delete(state.studyset.id);
emit(
StudysetEditState(

View file

@ -1,6 +1,8 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_edit_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:material_ui/material_ui.dart';
@ -10,16 +12,21 @@ import 'package:material_ui/material_ui.dart';
class const StudysetScreen({
/// The studyset as the screen opens; the bloc holds later changes.
required final Studyset studyset,
/// The person's cards, for the card list and the screens it opens.
required final CardRepository cards,
super.key,
}) extends StatelessWidget {
/// The route to [studyset]'s screen. Routes live above the person's scope,
/// so the person's repository is handed over here.
static Route<void> route(BuildContext context, Studyset studyset) {
final studysets = PersonScope.of(context).studysets;
final person = PersonScope.of(context);
final studysets = person.studysets;
final cards = person.cards;
return MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => StudysetEditBloc(studysets, studyset),
child: StudysetScreen(studyset: studyset),
create: (context) => StudysetEditBloc(studysets, cards, studyset),
child: StudysetScreen(studyset: studyset, cards: cards),
),
);
}
@ -81,6 +88,8 @@ class const StudysetScreen({
.add(StudysetEditEventDescriptionChanged(description)),
),
),
const SizedBox(height: 24),
CardList(cards: cards, studysetId: studyset.id),
],
),
),

View file

@ -35,6 +35,7 @@ dependencies:
flutter:
sdk: flutter
flutter_bloc: ^9.1.1
fractional_indexing_dart: ^1.0.7
halkyon_learn_client:
path: ../halkyon_learn_client
material_ui: ^1.5.0

View file

@ -127,4 +127,41 @@ void main() {
);
});
});
group('RichContent.plainText', () {
test('is the text without formatting, one line per block', () {
expect(
RichContent(paragraphs(['ひらがな', 'かたかな'])).plainText,
'ひらがな\nかたかな',
);
});
test('joins the pieces of a formatted block', () {
final content = RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'big '},
{
'insert': 'cat',
'attributes': {'bold': true},
},
],
},
},
],
},
});
expect(content.plainText, 'big cat');
});
test('is empty for empty content', () {
expect(RichContent.empty().plainText, isEmpty);
});
});
}

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