feat(app): card types, whose fields merge separately

A card has a type: front/back, multiple choice (drawn on a map when it
names one), vocabulary or matching, and the fields of that type. The card
payload's schema 2 holds the type and the fields in use; schema 1 reads
as front/back. Revisions merge per field rather than per side (ADR 0003,
amended): a field one device changed takes the change, a field changed
differently is a conflict, lists included as a whole.

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-01 18:17:00 +02:00
commit 927017bf15
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 695 additions and 71 deletions

View file

@ -33,6 +33,13 @@ conflict: "big" changed to "large" on one device and to "huge" on another become
version until the person keeps one. Merging happens when reading; nothing is written version until the person keeps one. Merging happens when reading; nothing is written
until the person saves. The code is until the person saves. The code is
`halkyon_learn_flutter/lib/src/feature/card/model/card_merge.dart`. `halkyon_learn_flutter/lib/src/feature/card/model/card_merge.dart`.
**Amended 2026-10-01, card types:** a card now has a type (front/back, multiple
choice, vocabulary, matching) and the fields of that type, and the merge runs per
field instead of per side: the type itself, the front, the back, the answers, the
reading, the meanings, the matching, the map. A list (the answers, the meanings)
merges as one field: two devices changing it differently are a conflict, as two
versions of one side were. Finer merging, per answer, would need a stable id per
answer; it can come when shared spaces make concurrent edits of one card common.
3. **An explicit position.** `Card.position` is a fractional index (rocicorp's 3. **An explicit position.** `Card.position` is a fractional index (rocicorp's
algorithm, through `fractional_indexing_dart`, checked against the algorithm's algorithm, through `fractional_indexing_dart`, checked against the algorithm's
reference test vectors) in a column of its own, where the last writer wins. Two devices reference test vectors) in a column of its own, where the last writer wins. Two devices

View file

@ -1,6 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.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/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.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -56,8 +55,8 @@ class CardEditBloc(
on<CardEditEventDeleteRequested>(_onDeleteRequested); on<CardEditEventDeleteRequested>(_onDeleteRequested);
} }
CardEditState _withSide(CardSide side, RichContent content) => state.copyWith( CardEditState _withSide(CardField side, Object? content) => state.copyWith(
content: state.content.withSide(side, content), content: state.content.withField(side, content),
resolvedSides: {...state.resolvedSides, side}, resolvedSides: {...state.resolvedSides, side},
); );
@ -115,10 +114,10 @@ final class const CardEditEventTagsChanged(
/// [side] was edited to [content]. /// [side] was edited to [content].
final class const CardEditEventSideChanged( final class const CardEditEventSideChanged(
/// The side edited. /// The side edited.
final CardSide side, final CardField side,
/// Its new content. /// Its new content.
final RichContent content, final Object? content,
) extends CardEditEvent { ) extends CardEditEvent {
@override @override
List<Object?> get props => [side, content]; List<Object?> get props => [side, content];
@ -127,10 +126,10 @@ final class const CardEditEventSideChanged(
/// For a side changed differently on two devices, the person keeps [version]. /// For a side changed differently on two devices, the person keeps [version].
final class const CardEditEventVersionChosen( final class const CardEditEventVersionChosen(
/// The conflicted side. /// The conflicted side.
final CardSide side, final CardField side,
/// The version to keep. /// The version to keep.
final RichContent version, final Object? version,
) extends CardEditEvent { ) extends CardEditEvent {
@override @override
List<Object?> get props => [side, version]; List<Object?> get props => [side, version];
@ -151,7 +150,7 @@ final class const CardEditState({
final Card? card, final Card? card,
/// The conflicted sides the person has settled. /// The conflicted sides the person has settled.
final Set<CardSide> resolvedSides = const {}, final Set<CardField> resolvedSides = const {},
/// Saved or deleted; the screen closes then. /// Saved or deleted; the screen closes then.
final bool isDone = false, final bool isDone = false,
@ -166,7 +165,7 @@ final class const CardEditState({
final Set<String>? tagIds, final Set<String>? tagIds,
}) with Equatable { }) with Equatable {
/// The sides devices changed differently that still need a choice. /// The sides devices changed differently that still need a choice.
Set<CardSide> get unresolvedSides => { Set<CardField> get unresolvedSides => {
...?card?.conflicts.keys, ...?card?.conflicts.keys,
}.difference(resolvedSides); }.difference(resolvedSides);
@ -197,7 +196,7 @@ final class const CardEditState({
/// This state with some fields replaced. /// This state with some fields replaced.
CardEditState copyWith({ CardEditState copyWith({
CardContent? content, CardContent? content,
Set<CardSide>? resolvedSides, Set<CardField>? resolvedSides,
bool? isDone, bool? isDone,
Tagging? tagging, Tagging? tagging,
Set<String>? savedTagIds, Set<String>? savedTagIds,

View file

@ -6,21 +6,48 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
// A card revision's content as its synced row stores it (see // A card revision's content as its synced row stores it (see
// core/content/payload). // core/content/payload).
//
// Schema 1 held the front and the back. Schema 2 added the card's type and
// the fields of the other types (answers; reading and meanings; matching;
// map), each left out while empty; schema 1 reads as a front/back card.
/// The schema this app writes. /// The schema this app writes.
const cardSchemaVersion = 1; const cardSchemaVersion = 2;
/// Encodes a card's content into a revision's payload. /// Encodes a card's content into a revision's payload.
ByteData encodeCardPayload(CardContent content) => ByteData encodeCardPayload(CardContent content) =>
encodePayload(cardSchemaVersion, { encodePayload(cardSchemaVersion, {
'type': content.type.name,
'front': content.front.toJson(), 'front': content.front.toJson(),
'back': content.back.toJson(), 'back': content.back.toJson(),
if (content.answers.isNotEmpty)
'answers': [
for (final answer in content.answers)
{
'content': answer.content.toJson(),
'correct': answer.correct,
'region': ?answer.region,
},
],
if (content.reading.isNotEmpty) 'reading': content.reading,
if (content.meanings.isNotEmpty) 'meanings': content.meanings,
if (content.matching.targets.isNotEmpty ||
content.matching.items.isNotEmpty)
'matching': {
'targets': content.matching.targets,
'items': [
for (final item in content.matching.items)
{'text': item.text, 'target': item.target},
],
},
'map': ?content.map,
}); });
/// Decodes a revision's payload. /// Decodes a revision's payload.
/// ///
/// Throws a [FormatException] for anything else, including content written /// Throws a [FormatException] for anything else, including content written
/// by a newer app version in a schema this one doesn't know. /// by a newer app version in a schema this one doesn't know, or of a type it
/// doesn't know.
CardContent decodeCardPayload(ByteData payload) { CardContent decodeCardPayload(ByteData payload) {
final json = decodePayload(payload, newestSchema: cardSchemaVersion); final json = decodePayload(payload, newestSchema: cardSchemaVersion);
if (json case { if (json case {
@ -28,9 +55,88 @@ CardContent decodeCardPayload(ByteData payload) {
'back': final Map<String, Object?> back, 'back': final Map<String, Object?> back,
}) { }) {
return CardContent( return CardContent(
type: _type(json['type']),
front: RichContent.fromJson(front), front: RichContent.fromJson(front),
back: RichContent.fromJson(back), back: RichContent.fromJson(back),
answers: _answers(json['answers']),
reading: _optional<String>(json, 'reading') ?? '',
meanings: _strings(json['meanings'], 'meanings'),
matching: _matching(json['matching']),
map: _optional<String>(json, 'map'),
); );
} }
throw const FormatException('Not a card payload.'); throw const FormatException('Not a card payload.');
} }
CardType _type(Object? json) {
// Schema 1 knew only front/back cards.
if (json == null) {
return CardType.frontBack;
}
for (final type in CardType.values) {
if (type.name == json) {
return type;
}
}
throw FormatException('A card of a type this app does not know: $json.');
}
List<CardAnswer> _answers(Object? json) => switch (json) {
null => const [],
final List<Object?> answers => [
for (final answer in answers)
switch (answer) {
{
'content': final Map<String, Object?> content,
'correct': final bool correct,
} =>
CardAnswer(
content: RichContent.fromJson(content),
correct: correct,
region: switch (answer['region']) {
null => null,
final String region => region,
_ => throw const FormatException('A card has a bad region.'),
},
),
_ => throw const FormatException('A card has a bad answer.'),
},
],
_ => throw const FormatException('A card has bad answers.'),
};
Matching _matching(Object? json) => switch (json) {
null => const Matching(),
{
'targets': final List<Object?> targets,
'items': final List<Object?> items,
} =>
Matching(
targets: _strings(targets, 'targets'),
items: [
for (final item in items)
switch (item) {
{'text': final String text, 'target': final int target} =>
MatchingItem(text: text, target: target),
_ => throw const FormatException('A card has a bad item.'),
},
],
),
_ => throw const FormatException('A card has a bad matching.'),
};
List<String> _strings(Object? json, String key) => switch (json) {
null => const [],
final List<Object?> list when list.every((each) => each is String) =>
list.cast<String>(),
_ => throw FormatException('A card has bad $key.'),
};
/// The optional field [key] of [json]: null when absent, and a
/// [FormatException] when it holds something other than a [T].
T? _optional<T extends Object>(Map<String, Object?> json, String key) =>
switch (json[key]) {
null => null,
final T value => value,
_ => throw FormatException('A card payload has a malformed $key.'),
};

View file

@ -1,5 +1,4 @@
import 'package:equatable/equatable.dart'; 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_content.dart';
/// A flashcard as the app shows it: its revisions merged (`mergeRevisions`). /// A flashcard as the app shows it: its revisions merged (`mergeRevisions`).
@ -10,18 +9,19 @@ final class const Card({
/// Where the card stands in its studyset: a fractional index. /// Where the card stands in its studyset: a fractional index.
required final String position, required final String position,
/// The current content. For a side in [conflicts], the newest version shows /// The current content. For a field in [conflicts], the newest version
/// until the person chooses. /// shows until the person chooses.
required final CardContent content, required final CardContent content,
/// The revisions the next save replaces: all current heads. /// The revisions the next save replaces: all current heads.
required final Set<String> heads, required final Set<String> heads,
/// The sides changed differently on different devices, with every version, /// The fields changed differently on different devices, with every
/// oldest first. /// version, oldest first.
final Map<CardSide, List<RichContent>> conflicts = const {}, final Map<CardField, List<Object?>> conflicts = const {},
}) with Equatable { }) with Equatable {
/// Whether devices changed a side differently and the person has to choose. /// Whether devices changed a field differently and the person has to
/// choose.
bool get hasConflict => conflicts.isNotEmpty; bool get hasConflict => conflicts.isNotEmpty;
@override @override

View file

@ -1,39 +1,231 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// The sides of a card, which merge separately (decided 2026-09-30). /// What kind of card it is: how it is edited and studied. A closed set the
enum CardSide() { /// app knows; what a card is about goes into its tags.
/// The prompt. enum CardType() {
front, /// A prompt on the front and the answer to recall on the back.
frontBack,
/// The answer to recall. /// A question and the answers to choose from, at least one of them
back, /// correct; with a map, the region to point at on it.
multipleChoice,
/// A word: its expression on the front, its reading and its meanings.
vocabulary,
/// Items to sort into targets, each into exactly one.
matching,
} }
/// What a card shows: its [front], and the [back] to recall. /// The parts of a card, which merge separately when devices changed a card
/// differently (ADR 0003, amended 2026-10-01): a part one device changed
/// takes that change, a part changed differently is a conflict.
enum CardField() {
/// What kind of card it is.
type,
/// The prompt: the front, the question, the expression or what to sort.
front,
/// The answer of a front/back card.
back,
/// The answers of a multiple-choice card.
answers,
/// A word's reading.
reading,
/// A word's meanings.
meanings,
/// The targets and items of a matching card.
matching,
/// The map a multiple-choice card is drawn on.
map,
}
/// One answer of a multiple-choice card.
final class const CardAnswer({
/// The answer.
required final RichContent content,
/// Whether it is right.
final bool correct = false,
/// On a map, the code of the region it names, e.g. `JP-13`.
final String? region,
}) with Equatable {
@override
List<Object?> get props => [content, correct, region];
}
/// An item of a matching card and the target it belongs to.
final class const MatchingItem({
/// The item.
required final String text,
/// The index of its target.
required final int target,
}) with Equatable {
@override
List<Object?> get props => [text, target];
}
/// What a matching card sorts: [items] into [targets].
final class const Matching({
/// The targets, e.g. categories.
final List<String> targets = const [],
/// The items, each belonging to one target.
final List<MatchingItem> items = const [],
}) with Equatable {
@override
List<Object?> get props => [targets, items];
}
/// What a card holds: its [type] and the fields of that type. Fields of
/// other types stay empty.
final class const CardContent({ final class const CardContent({
/// The prompt. /// The prompt: the front, the question, the expression or what to sort.
required final RichContent front, required final RichContent front,
/// The answer to recall. /// The answer of a front/back card.
required final RichContent back, required final RichContent back,
/// What kind of card it is.
final CardType type = CardType.frontBack,
/// The answers of a multiple-choice card.
final List<CardAnswer> answers = const [],
/// A word's reading, e.g. たべる; empty for a word written in kana.
final String reading = '',
/// A word's meanings, the main one first.
final List<String> meanings = const [],
/// The targets and items of a matching card.
final Matching matching = const Matching(),
/// The id of the map a multiple-choice card is drawn on.
final String? map,
}) with Equatable { }) with Equatable {
/// A card with nothing on either side. /// A front/back card with nothing on either side.
factory empty() => factory empty() =>
CardContent(front: RichContent.empty(), back: RichContent.empty()); CardContent(front: RichContent.empty(), back: RichContent.empty());
/// The content of [side]. /// The value of [field].
RichContent operator [](CardSide side) => switch (side) { Object? operator [](CardField field) => switch (field) {
CardSide.front => front, CardField.type => type,
CardSide.back => back, CardField.front => front,
CardField.back => back,
CardField.answers => answers,
CardField.reading => reading,
CardField.meanings => meanings,
CardField.matching => matching,
CardField.map => map,
}; };
/// This content with [side] replaced by [content]. /// This content with [field] set to [value], which must be of the field's
CardContent withSide(CardSide side, RichContent content) => switch (side) { /// kind.
CardSide.front => CardContent(front: content, back: back), CardContent withField(CardField field, Object? value) => CardContent(
CardSide.back => CardContent(front: front, back: content), type: field == CardField.type ? value! as CardType : type,
front: field == CardField.front ? value! as RichContent : front,
back: field == CardField.back ? value! as RichContent : back,
answers: field == CardField.answers ? value! as List<CardAnswer> : answers,
reading: field == CardField.reading ? value! as String : reading,
meanings: field == CardField.meanings ? value! as List<String> : meanings,
matching: field == CardField.matching ? value! as Matching : matching,
map: field == CardField.map ? value as String? : map,
);
/// The fields [type] uses; the others stay empty.
Set<CardField> get fields => switch (type) {
CardType.frontBack => const {CardField.front, CardField.back},
CardType.multipleChoice when map != null => const {
CardField.front,
CardField.answers,
CardField.map,
},
CardType.multipleChoice => const {CardField.front, CardField.answers},
CardType.vocabulary => const {
CardField.front,
CardField.reading,
CardField.meanings,
},
CardType.matching => const {CardField.front, CardField.matching},
};
/// This content with only the fields of its [type]; the others empty, as
/// they are stored: a card switched to another type while being written
/// keeps the other type's fields only until it is saved.
CardContent get normalized {
final kept = fields;
var content = CardContent(
type: type,
front: RichContent.empty(),
back: RichContent.empty(),
);
for (final field in kept) {
content = content.withField(field, this[field]);
}
return content;
}
/// Whether the card can be studied: everything its type asks for is there.
bool get isComplete => switch (type) {
CardType.frontBack => !_blank(front) && !_blank(back),
CardType.multipleChoice when map != null =>
!_blank(front) && answers.any((answer) => answer.region != null),
CardType.multipleChoice =>
!_blank(front) &&
answers.length >= 2 &&
answers.every((answer) => !_blank(answer.content)) &&
answers.any((answer) => answer.correct),
CardType.vocabulary =>
!_blank(front) && meanings.any((meaning) => meaning.trim().isNotEmpty),
CardType.matching =>
matching.targets.isNotEmpty &&
matching.items.isNotEmpty &&
matching.items.every(
(item) =>
item.text.trim().isNotEmpty &&
item.target >= 0 &&
item.target < matching.targets.length,
),
};
/// What a list shows beside the front: the back, the right answers, the
/// reading and meanings, or the targets; one line.
String get answerPreview => switch (type) {
CardType.frontBack => _firstLine(back.plainText),
CardType.multipleChoice => [
for (final answer in answers)
if (answer.correct) _firstLine(answer.content.plainText),
].join(' · '),
CardType.vocabulary => [
if (reading.trim().isNotEmpty) reading.trim(),
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
].where((part) => part.isNotEmpty).join(' · '),
CardType.matching => matching.targets.join(' · '),
}; };
@override @override
List<Object?> get props => [front, back]; List<Object?> get props => [
type,
front,
back,
answers,
reading,
meanings,
matching,
map,
];
} }
bool _blank(RichContent content) => content.plainText.trim().isEmpty;
String _firstLine(String text) => text.split('\n').first;

View file

@ -1,20 +1,19 @@
import 'package:equatable/equatable.dart'; 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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_revision.dart';
/// A card's content, merged from its revisions by [mergeRevisions]. /// A card's content, merged from its revisions by [mergeRevisions].
final class const MergedCard({ final class const MergedCard({
/// The current content. For a side in [conflicts], the newest version shows /// The current content. For a field in [conflicts], the newest version
/// until the person chooses. /// shows until the person chooses.
required final CardContent content, required final CardContent content,
/// The revisions the next edit replaces: all current heads. /// The revisions the next edit replaces: all current heads.
required final Set<String> heads, required final Set<String> heads,
/// The sides changed differently on different devices, with every version, /// The fields changed differently on different devices, with every
/// oldest first. /// version, oldest first.
final Map<CardSide, List<RichContent>> conflicts = const {}, final Map<CardField, List<Object?>> conflicts = const {},
}) with Equatable { }) with Equatable {
@override @override
List<Object?> get props => [content, heads, conflicts]; List<Object?> get props => [content, heads, conflicts];
@ -24,9 +23,9 @@ final class const MergedCard({
/// ///
/// The heads are the revisions no other revision replaces. One head is the /// The heads are the revisions no other revision replaces. One head is the
/// current version. Several heads, from edits on different devices while /// current version. Several heads, from edits on different devices while
/// offline, merge side by side against their newest common ancestor: a side /// offline, merge field by field against their newest common ancestor: a
/// only one of them changed takes that change, a side they changed /// field only one of them changed takes that change, a field they changed
/// differently is a conflict. Nothing is merged silently inside a side. /// differently is a conflict. Nothing is merged silently inside a field.
/// Revisions whose parents haven't arrived yet count as they are. /// Revisions whose parents haven't arrived yet count as they are.
MergedCard? mergeRevisions(Iterable<CardRevision> revisions) { MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
final byId = {for (final revision in revisions) revision.id: revision}; final byId = {for (final revision in revisions) revision.id: revision};
@ -45,13 +44,14 @@ MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
final base = _commonAncestor(heads, byId)?.content; final base = _commonAncestor(heads, byId)?.content;
var content = heads.last.content; var content = heads.last.content;
final conflicts = <CardSide, List<RichContent>>{}; final conflicts = <CardField, List<Object?>>{};
for (final side in CardSide.values) { for (final field in CardField.values) {
// What the heads changed this side to, oldest first, without repeats. // What the heads changed this field to, oldest first, without repeats.
final changed = <RichContent>[]; final changed = <Object?>[];
for (final head in heads) { for (final head in heads) {
final version = head.content[side]; final version = head.content[field];
if (version != base?[side] && !changed.contains(version)) { if (!_same(version, base?[field]) &&
!changed.any((each) => _same(each, version))) {
changed.add(version); changed.add(version);
} }
} }
@ -60,10 +60,10 @@ MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
// Unchanged everywhere; the newest head holds it already. // Unchanged everywhere; the newest head holds it already.
break; break;
case [final only]: case [final only]:
content = content.withSide(side, only); content = content.withField(field, only);
default: default:
conflicts[side] = changed; conflicts[field] = changed;
content = content.withSide(side, changed.last); content = content.withField(field, changed.last);
} }
} }
return MergedCard( return MergedCard(
@ -105,3 +105,11 @@ CardRevision? _commonAncestor(
]..sort(); ]..sort();
return nearest.isEmpty ? null : byId[nearest.last]; return nearest.isEmpty ? null : byId[nearest.last];
} }
/// Whether two values of a field are the same: lists by their elements.
bool _same(Object? a, Object? b) => switch ((a, b)) {
(final List<Object?> a, final List<Object?> b) =>
a.length == b.length &&
[for (var i = 0; i < a.length; i++) a[i] == b[i]].every((same) => same),
_ => a == b,
};

View file

@ -67,23 +67,20 @@ class const CardScreen({
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
for (final side in CardSide.values) ...[ for (final side in const [CardField.front, CardField.back]) ...[
Text( Text(
switch (side) { side == CardField.front ? 'Front' : 'Back',
CardSide.front => 'Front',
CardSide.back => 'Back',
},
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
), ),
if (state.unresolvedSides.contains(side)) if (state.unresolvedSides.contains(side))
for (final version in state.card!.conflicts[side]!) for (final version in state.card!.conflicts[side]!)
_Version(side: side, version: version) _Version(side: side, version: version! as RichContent)
else else
SizedBox( SizedBox(
height: 200, height: 200,
child: RichContentEditor( child: RichContentEditor(
key: ValueKey(side), key: ValueKey(side),
initialContent: state.content[side], initialContent: state.content[side]! as RichContent,
onChanged: (content) => context onChanged: (content) => context
.read<CardEditBloc>() .read<CardEditBloc>()
.add(CardEditEventSideChanged(side, content)), .add(CardEditEventSideChanged(side, content)),
@ -144,7 +141,7 @@ class const _Tags({required final CardEditState state})
/// One version of a side devices changed differently, and keeping it. /// One version of a side devices changed differently, and keeping it.
class const _Version({ class const _Version({
required final CardSide side, required final CardField side,
required final RichContent version, required final RichContent version,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override

View file

@ -159,7 +159,7 @@ void main() {
addTearDown(bloc.close); addTearDown(bloc.close);
expect(bloc.state.canSave, isFalse); expect(bloc.state.canSave, isFalse);
bloc.add(CardEditEventSideChanged(CardSide.front, text('猫'))); bloc.add(CardEditEventSideChanged(CardField.front, text('猫')));
await expectLater( await expectLater(
bloc.stream, bloc.stream,
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)), emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
@ -187,7 +187,7 @@ void main() {
addTearDown(bloc.close); addTearDown(bloc.close);
expect(bloc.state.canSave, isFalse); expect(bloc.state.canSave, isFalse);
bloc.add(CardEditEventSideChanged(CardSide.back, text('a cat'))); bloc.add(CardEditEventSideChanged(CardField.back, text('a cat')));
await expectLater( await expectLater(
bloc.stream, bloc.stream,
@ -215,13 +215,13 @@ void main() {
tearDown(() => bloc.close()); tearDown(() => bloc.close());
test('waits for a version of every conflicted side', () { test('waits for a version of every conflicted side', () {
expect(bloc.state.unresolvedSides, {CardSide.back}); expect(bloc.state.unresolvedSides, {CardField.back});
expect(bloc.state.canSave, isFalse); expect(bloc.state.canSave, isFalse);
}); });
test('choosing a version resolves the side; saving resolves the ' test('choosing a version resolves the side; saving resolves the '
'card', () async { 'card', () async {
bloc.add(CardEditEventVersionChosen(CardSide.back, text('a cat'))); bloc.add(CardEditEventVersionChosen(CardField.back, text('a cat')));
await expectLater( await expectLater(
bloc.stream, bloc.stream,
emits( emits(
@ -282,7 +282,7 @@ void main() {
bloc bloc
..add(CardEditEventTagsChanged({noun})) ..add(CardEditEventTagsChanged({noun}))
..add(CardEditEventSideChanged(CardSide.front, text('猫'))) ..add(CardEditEventSideChanged(CardField.front, text('猫')))
..add(const CardEditEventSaveRequested()); ..add(const CardEditEventSaveRequested());
await bloc.stream.firstWhere((s) => s.isDone); await bloc.stream.firstWhere((s) => s.isDone);

View file

@ -43,4 +43,89 @@ void main() {
throwsFormatException, throwsFormatException,
); );
}); });
test('every type survives a round trip with its fields', () {
final contents = [
CardContent(
type: CardType.multipleChoice,
front: text('Capital of Japan?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: text('Tokyo'), correct: true),
CardAnswer(content: text('Kyoto')),
],
),
CardContent(
type: CardType.multipleChoice,
front: text('Where is Tokyo?'),
back: RichContent.empty(),
answers: [
CardAnswer(content: text('Tokyo'), correct: true, region: 'JP-13'),
],
map: 'japan',
),
CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat', 'to live on'],
),
CardContent(
type: CardType.matching,
front: text('Sort the animals'),
back: RichContent.empty(),
matching: const Matching(
targets: ['mammal', 'bird'],
items: [
MatchingItem(text: 'cat', target: 0),
MatchingItem(text: 'crow', target: 1),
],
),
),
];
for (final content in contents) {
expect(decodeCardPayload(encodeCardPayload(content)), content);
}
});
test('leaves out the fields a card leaves empty', () {
final payload = encodeCardPayload(
CardContent(front: text('猫'), back: text('cat')),
);
expect(decodePayload(payload, newestSchema: cardSchemaVersion).keys, {
'schemaVersion',
'type',
'front',
'back',
});
});
test('reads a card of the first schema as a front/back card', () {
final content = decodeCardPayload(
bytes({
'schemaVersion': 1,
'front': text('猫').toJson(),
'back': text('cat').toJson(),
}),
);
expect(content, CardContent(front: text('猫'), back: text('cat')));
});
test('refuses a type this app does not know', () {
expect(
() => decodeCardPayload(
bytes({
'schemaVersion': cardSchemaVersion,
'type': 'hologram',
'front': RichContent.empty().toJson(),
'back': RichContent.empty().toJson(),
}),
),
throwsFormatException,
);
});
} }

View file

@ -109,7 +109,7 @@ void main() {
await repository.save(created, card('猫', 'a cat')); await repository.save(created, card('猫', 'a cat'));
remoteEdit(card('猫', 'the cat')); remoteEdit(card('猫', 'the cat'));
final conflicted = (await repository.cardsOf('japanese')).single; final conflicted = (await repository.cardsOf('japanese')).single;
expect(conflicted.conflicts.keys, [CardSide.back]); expect(conflicted.conflicts.keys, [CardField.back]);
await repository.save(conflicted, card('猫', 'cat')); await repository.save(conflicted, card('猫', 'cat'));

View file

@ -0,0 +1,155 @@
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/card/model/card_content.dart';
void main() {
RichContent text(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
final none = RichContent.empty();
group('A card is complete', () {
test('front/back: with both sides', () {
expect(CardContent(front: text('猫'), back: text('cat')).isComplete, true);
expect(CardContent(front: text('猫'), back: none).isComplete, false);
});
test('multiple choice: with two answers or more, every one written, '
'one of them right', () {
CardContent question(List<CardAnswer> answers) => CardContent(
type: CardType.multipleChoice,
front: text('Capital of Japan?'),
back: none,
answers: answers,
);
final tokyo = CardAnswer(content: text('Tokyo'), correct: true);
final kyoto = CardAnswer(content: text('Kyoto'));
expect(question([tokyo, kyoto]).isComplete, true);
expect(question([tokyo]).isComplete, false);
expect(question([kyoto, kyoto]).isComplete, false);
expect(question([tokyo, CardAnswer(content: none)]).isComplete, false);
});
test('on a map: with a prompt and the region to point at', () {
final where = CardContent(
type: CardType.multipleChoice,
front: text('Where is Tokyo?'),
back: none,
answers: [
CardAnswer(content: text('Tokyo'), correct: true, region: 'JP-13'),
],
map: 'japan',
);
expect(where.isComplete, true);
expect(
where.withField(CardField.answers, <CardAnswer>[]).isComplete,
false,
);
});
test('vocabulary: with the expression and a meaning', () {
final word = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: none,
meanings: const ['to eat'],
);
expect(word.isComplete, true);
expect(word.withField(CardField.meanings, const [' ']).isComplete, false);
});
test('matching: with targets, and items each in one of them', () {
CardContent sorting(int target) => CardContent(
type: CardType.matching,
front: none,
back: none,
matching: Matching(
targets: const ['mammal', 'bird'],
items: [MatchingItem(text: 'cat', target: target)],
),
);
expect(sorting(0).isComplete, true);
expect(sorting(2).isComplete, false);
});
});
test('previews the answer as its type has it', () {
expect(
CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: none,
reading: 'たべる',
meanings: const ['to eat', 'to live on'],
).answerPreview,
'たべる · to eat; to live on',
);
expect(
CardContent(
type: CardType.multipleChoice,
front: text('Capital?'),
back: none,
answers: [
CardAnswer(content: text('Tokyo'), correct: true),
CardAnswer(content: text('Kyoto')),
],
).answerPreview,
'Tokyo',
);
});
test('uses only the fields of its type', () {
expect(
CardContent(front: none, back: none).fields,
{CardField.front, CardField.back},
);
expect(
CardContent(
type: CardType.multipleChoice,
front: none,
back: none,
map: 'japan',
).fields,
{CardField.front, CardField.answers, CardField.map},
);
});
test('normalized keeps only the fields of its type', () {
final switched = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: text('to eat'),
answers: [CardAnswer(content: text('Tokyo'), correct: true)],
reading: 'たべる',
meanings: const ['to eat'],
);
expect(
switched.normalized,
CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: none,
reading: 'たべる',
meanings: const ['to eat'],
),
);
});
}

View file

@ -71,7 +71,7 @@ void main() {
])!; ])!;
expect(merged.conflicts, { expect(merged.conflicts, {
CardSide.front: [text('large'), text('huge')], CardField.front: [text('large'), text('huge')],
}); });
// Until the person chooses, the newest version shows. // Until the person chooses, the newest version shows.
expect(merged.content, card('huge', 'gross')); expect(merged.content, card('huge', 'gross'));
@ -101,7 +101,7 @@ void main() {
expect(merged.heads, {'r2', 'r3', 'r4'}); expect(merged.heads, {'r2', 'r3', 'r4'});
expect(merged.conflicts, { expect(merged.conflicts, {
CardSide.front: [text('large'), text('huge')], CardField.front: [text('large'), text('huge')],
}); });
expect(merged.content.back, text('gross')); expect(merged.content.back, text('gross'));
}); });
@ -131,4 +131,79 @@ void main() {
test('a card without revisions has no content', () { test('a card without revisions has no content', () {
expect(mergeRevisions([]), isNull); expect(mergeRevisions([]), isNull);
}); });
group('of the other card types', () {
CardContent word({
String reading = 'たべる',
List<String> meanings = const ['to eat'],
}) => CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: reading,
meanings: meanings,
);
test('a word whose reading one device changed and whose meanings '
'another changed keeps both', () {
final merged = mergeRevisions([
revision('r1', word()),
revision('r2', word(reading: 'タベル'), parents: {'r1'}),
revision(
'r3',
word(meanings: ['to eat', 'to live on']),
parents: {'r1'},
),
])!;
expect(merged.conflicts, isEmpty);
expect(
merged.content,
word(reading: 'タベル', meanings: ['to eat', 'to live on']),
);
});
test('the same list of meanings on two devices is no conflict, '
'different ones are', () {
final same = mergeRevisions([
revision('r1', word()),
revision('r2', word(meanings: ['to eat', 'to dine']), parents: {'r1'}),
revision('r3', word(meanings: ['to eat', 'to dine']), parents: {'r1'}),
])!;
final different = mergeRevisions([
revision('r1', word()),
revision('r2', word(meanings: ['to dine']), parents: {'r1'}),
revision('r3', word(meanings: ['to feed']), parents: {'r1'}),
])!;
expect(same.conflicts, isEmpty);
expect(different.conflicts, {
CardField.meanings: [
['to dine'],
['to feed'],
],
});
});
test('answers changed differently are a conflict as a whole', () {
CardContent question(List<String> answers) => CardContent(
type: CardType.multipleChoice,
front: text('Capital of Japan?'),
back: RichContent.empty(),
answers: [
for (final (i, answer) in answers.indexed)
CardAnswer(content: text(answer), correct: i == 0),
],
);
final merged = mergeRevisions([
revision('r1', question(['Tokyo', 'Kyoto'])),
revision('r2', question(['Tokyo', 'Osaka']), parents: {'r1'}),
revision('r3', question(['Tokyo', 'Nara']), parents: {'r1'}),
])!;
expect(merged.conflicts.keys, [CardField.answers]);
expect(merged.conflicts[CardField.answers], hasLength(2));
});
});
} }

View file

@ -115,7 +115,7 @@ void main() {
await syncBoth(phone, laptop); await syncBoth(phone, laptop);
for (final device in [phone, laptop]) { for (final device in [phone, laptop]) {
expect((await device.card(studyset, id))!.conflicts.keys, [ expect((await device.card(studyset, id))!.conflicts.keys, [
CardSide.back, CardField.back,
]); ]);
} }