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>
2026-09-30 14:41:44 +02:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
|
|
|
|
|
|
|
|
|
/// A [CardGateway] in memory. Ids sort by creation, as UUID v7 ids do: cards
|
|
|
|
|
/// are `card-0001`, `card-0002`, …, revisions `rev-0001`, …;
|
|
|
|
|
/// [receiveRemoteRevision] plays a sync that brought another device's edit.
|
|
|
|
|
class FakeCardGateway() implements CardGateway {
|
|
|
|
|
final _cards = <String, ({String studysetId, String position})>{};
|
|
|
|
|
final _revisions = <String, CardRevisionRow>{};
|
|
|
|
|
final _remoteChanges = StreamController<void>.broadcast();
|
|
|
|
|
var _nextId = 1;
|
|
|
|
|
|
|
|
|
|
/// Every revision written on this device, in order.
|
|
|
|
|
final written = <CardRevisionRow>[];
|
|
|
|
|
|
|
|
|
|
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
|
|
|
|
|
final deletions = <String>[];
|
|
|
|
|
|
2026-10-01 18:21:54 +02:00
|
|
|
/// When set, storing a card or a revision fails with it.
|
|
|
|
|
Exception? failWrites;
|
|
|
|
|
|
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>
2026-09-30 14:41:44 +02:00
|
|
|
/// 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 cardId,
|
|
|
|
|
required Set<String> parents,
|
|
|
|
|
required ByteData payload,
|
|
|
|
|
}) {
|
|
|
|
|
_revisions[id] = (
|
|
|
|
|
id: id,
|
|
|
|
|
cardId: cardId,
|
|
|
|
|
parents: parents,
|
|
|
|
|
payload: payload,
|
|
|
|
|
);
|
|
|
|
|
_remoteChanges.add(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<List<CardRow>> cardsOf(String studysetId) async => [
|
|
|
|
|
for (final MapEntry(key: id, value: card) in _cards.entries)
|
|
|
|
|
if (card.studysetId == studysetId) (id: id, position: card.position),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async => [
|
|
|
|
|
for (final revision in _revisions.values)
|
|
|
|
|
if (cardIds.contains(revision.cardId)) revision,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<String> insertCard({
|
|
|
|
|
required String studysetId,
|
|
|
|
|
required String position,
|
|
|
|
|
required ByteData payload,
|
|
|
|
|
}) async {
|
2026-10-01 18:21:54 +02:00
|
|
|
if (failWrites case final error?) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
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>
2026-09-30 14:41:44 +02:00
|
|
|
final id = _newId('card');
|
|
|
|
|
_cards[id] = (studysetId: studysetId, position: position);
|
|
|
|
|
await insertRevision(cardId: id, parents: const {}, payload: payload);
|
|
|
|
|
return id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> insertRevision({
|
|
|
|
|
required String cardId,
|
|
|
|
|
required Set<String> parents,
|
|
|
|
|
required ByteData payload,
|
|
|
|
|
}) async {
|
2026-10-01 18:21:54 +02:00
|
|
|
if (failWrites case final error?) {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
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>
2026-09-30 14:41:44 +02:00
|
|
|
final revision = (
|
|
|
|
|
id: _newId('rev'),
|
|
|
|
|
cardId: cardId,
|
|
|
|
|
parents: parents,
|
|
|
|
|
payload: payload,
|
|
|
|
|
);
|
|
|
|
|
_revisions[revision.id] = revision;
|
|
|
|
|
written.add(revision);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> updatePosition(String cardId, String position) async {
|
|
|
|
|
if (_cards[cardId] case final card?) {
|
|
|
|
|
_cards[cardId] = (studysetId: card.studysetId, position: position);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> blankRevisions(String cardId) async {
|
|
|
|
|
deletions.add('blank $cardId');
|
|
|
|
|
for (final revision in [..._revisions.values]) {
|
|
|
|
|
if (revision.cardId == cardId) {
|
|
|
|
|
_revisions[revision.id] = (
|
|
|
|
|
id: revision.id,
|
|
|
|
|
cardId: cardId,
|
|
|
|
|
parents: revision.parents,
|
|
|
|
|
payload: redactedPayload,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> deleteCard(String cardId) async {
|
|
|
|
|
deletions.add('delete $cardId');
|
|
|
|
|
_cards.remove(cardId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Stream<void> get remoteChanges => _remoteChanges.stream;
|
|
|
|
|
|
|
|
|
|
String _newId(String kind) =>
|
|
|
|
|
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
|
|
|
|
|
}
|