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:
parent
f66595a1d6
commit
927017bf15
12 changed files with 695 additions and 71 deletions
|
|
@ -1,6 +1,5 @@
|
|||
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';
|
||||
|
|
@ -56,8 +55,8 @@ class CardEditBloc(
|
|||
on<CardEditEventDeleteRequested>(_onDeleteRequested);
|
||||
}
|
||||
|
||||
CardEditState _withSide(CardSide side, RichContent content) => state.copyWith(
|
||||
content: state.content.withSide(side, content),
|
||||
CardEditState _withSide(CardField side, Object? content) => state.copyWith(
|
||||
content: state.content.withField(side, content),
|
||||
resolvedSides: {...state.resolvedSides, side},
|
||||
);
|
||||
|
||||
|
|
@ -115,10 +114,10 @@ final class const CardEditEventTagsChanged(
|
|||
/// [side] was edited to [content].
|
||||
final class const CardEditEventSideChanged(
|
||||
/// The side edited.
|
||||
final CardSide side,
|
||||
final CardField side,
|
||||
|
||||
/// Its new content.
|
||||
final RichContent content,
|
||||
final Object? content,
|
||||
) extends CardEditEvent {
|
||||
@override
|
||||
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].
|
||||
final class const CardEditEventVersionChosen(
|
||||
/// The conflicted side.
|
||||
final CardSide side,
|
||||
final CardField side,
|
||||
|
||||
/// The version to keep.
|
||||
final RichContent version,
|
||||
final Object? version,
|
||||
) extends CardEditEvent {
|
||||
@override
|
||||
List<Object?> get props => [side, version];
|
||||
|
|
@ -151,7 +150,7 @@ final class const CardEditState({
|
|||
final Card? card,
|
||||
|
||||
/// The conflicted sides the person has settled.
|
||||
final Set<CardSide> resolvedSides = const {},
|
||||
final Set<CardField> resolvedSides = const {},
|
||||
|
||||
/// Saved or deleted; the screen closes then.
|
||||
final bool isDone = false,
|
||||
|
|
@ -166,7 +165,7 @@ final class const CardEditState({
|
|||
final Set<String>? tagIds,
|
||||
}) with Equatable {
|
||||
/// The sides devices changed differently that still need a choice.
|
||||
Set<CardSide> get unresolvedSides => {
|
||||
Set<CardField> get unresolvedSides => {
|
||||
...?card?.conflicts.keys,
|
||||
}.difference(resolvedSides);
|
||||
|
||||
|
|
@ -197,7 +196,7 @@ final class const CardEditState({
|
|||
/// This state with some fields replaced.
|
||||
CardEditState copyWith({
|
||||
CardContent? content,
|
||||
Set<CardSide>? resolvedSides,
|
||||
Set<CardField>? resolvedSides,
|
||||
bool? isDone,
|
||||
Tagging? tagging,
|
||||
Set<String>? savedTagIds,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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.
|
||||
const cardSchemaVersion = 1;
|
||||
const cardSchemaVersion = 2;
|
||||
|
||||
/// Encodes a card's content into a revision's payload.
|
||||
ByteData encodeCardPayload(CardContent content) =>
|
||||
encodePayload(cardSchemaVersion, {
|
||||
'type': content.type.name,
|
||||
'front': content.front.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.
|
||||
///
|
||||
/// 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) {
|
||||
final json = decodePayload(payload, newestSchema: cardSchemaVersion);
|
||||
if (json case {
|
||||
|
|
@ -28,9 +55,88 @@ CardContent decodeCardPayload(ByteData payload) {
|
|||
'back': final Map<String, Object?> back,
|
||||
}) {
|
||||
return CardContent(
|
||||
type: _type(json['type']),
|
||||
front: RichContent.fromJson(front),
|
||||
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.');
|
||||
}
|
||||
|
||||
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.'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
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`).
|
||||
|
|
@ -10,18 +9,19 @@ final class const Card({
|
|||
/// 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.
|
||||
/// The current content. For a field 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 {},
|
||||
/// The fields changed differently on different devices, with every
|
||||
/// version, oldest first.
|
||||
final Map<CardField, List<Object?>> conflicts = const {},
|
||||
}) 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;
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1,39 +1,231 @@
|
|||
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,
|
||||
/// What kind of card it is: how it is edited and studied. A closed set the
|
||||
/// app knows; what a card is about goes into its tags.
|
||||
enum CardType() {
|
||||
/// A prompt on the front and the answer to recall on the back.
|
||||
frontBack,
|
||||
|
||||
/// The answer to recall.
|
||||
back,
|
||||
/// A question and the answers to choose from, at least one of them
|
||||
/// 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({
|
||||
/// The prompt.
|
||||
/// The prompt: the front, the question, the expression or what to sort.
|
||||
required final RichContent front,
|
||||
|
||||
/// The answer to recall.
|
||||
/// The answer of a front/back card.
|
||||
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 {
|
||||
/// A card with nothing on either side.
|
||||
/// A front/back 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,
|
||||
/// The value of [field].
|
||||
Object? operator [](CardField field) => switch (field) {
|
||||
CardField.type => type,
|
||||
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].
|
||||
CardContent withSide(CardSide side, RichContent content) => switch (side) {
|
||||
CardSide.front => CardContent(front: content, back: back),
|
||||
CardSide.back => CardContent(front: front, back: content),
|
||||
/// This content with [field] set to [value], which must be of the field's
|
||||
/// kind.
|
||||
CardContent withField(CardField field, Object? value) => CardContent(
|
||||
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
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
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.
|
||||
/// The current content. For a field 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 {},
|
||||
/// The fields changed differently on different devices, with every
|
||||
/// version, oldest first.
|
||||
final Map<CardField, List<Object?>> conflicts = const {},
|
||||
}) with Equatable {
|
||||
@override
|
||||
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
|
||||
/// 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.
|
||||
/// offline, merge field by field against their newest common ancestor: a
|
||||
/// field only one of them changed takes that change, a field they changed
|
||||
/// differently is a conflict. Nothing is merged silently inside a field.
|
||||
/// 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};
|
||||
|
|
@ -45,13 +44,14 @@ MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
|
|||
|
||||
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>[];
|
||||
final conflicts = <CardField, List<Object?>>{};
|
||||
for (final field in CardField.values) {
|
||||
// What the heads changed this field to, oldest first, without repeats.
|
||||
final changed = <Object?>[];
|
||||
for (final head in heads) {
|
||||
final version = head.content[side];
|
||||
if (version != base?[side] && !changed.contains(version)) {
|
||||
final version = head.content[field];
|
||||
if (!_same(version, base?[field]) &&
|
||||
!changed.any((each) => _same(each, version))) {
|
||||
changed.add(version);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,10 +60,10 @@ MergedCard? mergeRevisions(Iterable<CardRevision> revisions) {
|
|||
// Unchanged everywhere; the newest head holds it already.
|
||||
break;
|
||||
case [final only]:
|
||||
content = content.withSide(side, only);
|
||||
content = content.withField(field, only);
|
||||
default:
|
||||
conflicts[side] = changed;
|
||||
content = content.withSide(side, changed.last);
|
||||
conflicts[field] = changed;
|
||||
content = content.withField(field, changed.last);
|
||||
}
|
||||
}
|
||||
return MergedCard(
|
||||
|
|
@ -105,3 +105,11 @@ CardRevision? _commonAncestor(
|
|||
]..sort();
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -67,23 +67,20 @@ class const CardScreen({
|
|||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
for (final side in CardSide.values) ...[
|
||||
for (final side in const [CardField.front, CardField.back]) ...[
|
||||
Text(
|
||||
switch (side) {
|
||||
CardSide.front => 'Front',
|
||||
CardSide.back => 'Back',
|
||||
},
|
||||
side == CardField.front ? 'Front' : '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)
|
||||
_Version(side: side, version: version! as RichContent)
|
||||
else
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: RichContentEditor(
|
||||
key: ValueKey(side),
|
||||
initialContent: state.content[side],
|
||||
initialContent: state.content[side]! as RichContent,
|
||||
onChanged: (content) => context
|
||||
.read<CardEditBloc>()
|
||||
.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.
|
||||
class const _Version({
|
||||
required final CardSide side,
|
||||
required final CardField side,
|
||||
required final RichContent version,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ void main() {
|
|||
addTearDown(bloc.close);
|
||||
expect(bloc.state.canSave, isFalse);
|
||||
|
||||
bloc.add(CardEditEventSideChanged(CardSide.front, text('猫')));
|
||||
bloc.add(CardEditEventSideChanged(CardField.front, text('猫')));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
||||
|
|
@ -187,7 +187,7 @@ void main() {
|
|||
addTearDown(bloc.close);
|
||||
expect(bloc.state.canSave, isFalse);
|
||||
|
||||
bloc.add(CardEditEventSideChanged(CardSide.back, text('a cat')));
|
||||
bloc.add(CardEditEventSideChanged(CardField.back, text('a cat')));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
|
|
@ -215,13 +215,13 @@ void main() {
|
|||
tearDown(() => bloc.close());
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('choosing a version resolves the side; saving resolves the '
|
||||
'card', () async {
|
||||
bloc.add(CardEditEventVersionChosen(CardSide.back, text('a cat')));
|
||||
bloc.add(CardEditEventVersionChosen(CardField.back, text('a cat')));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
|
|
@ -282,7 +282,7 @@ void main() {
|
|||
|
||||
bloc
|
||||
..add(CardEditEventTagsChanged({noun}))
|
||||
..add(CardEditEventSideChanged(CardSide.front, text('猫')))
|
||||
..add(CardEditEventSideChanged(CardField.front, text('猫')))
|
||||
..add(const CardEditEventSaveRequested());
|
||||
await bloc.stream.firstWhere((s) => s.isDone);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,4 +43,89 @@ void main() {
|
|||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ void main() {
|
|||
await repository.save(created, card('猫', 'a cat'));
|
||||
remoteEdit(card('猫', 'the cat'));
|
||||
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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ void main() {
|
|||
])!;
|
||||
|
||||
expect(merged.conflicts, {
|
||||
CardSide.front: [text('large'), text('huge')],
|
||||
CardField.front: [text('large'), text('huge')],
|
||||
});
|
||||
// Until the person chooses, the newest version shows.
|
||||
expect(merged.content, card('huge', 'gross'));
|
||||
|
|
@ -101,7 +101,7 @@ void main() {
|
|||
|
||||
expect(merged.heads, {'r2', 'r3', 'r4'});
|
||||
expect(merged.conflicts, {
|
||||
CardSide.front: [text('large'), text('huge')],
|
||||
CardField.front: [text('large'), text('huge')],
|
||||
});
|
||||
expect(merged.content.back, text('gross'));
|
||||
});
|
||||
|
|
@ -131,4 +131,79 @@ void main() {
|
|||
test('a card without revisions has no content', () {
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ void main() {
|
|||
await syncBoth(phone, laptop);
|
||||
for (final device in [phone, laptop]) {
|
||||
expect((await device.card(studyset, id))!.conflicts.keys, [
|
||||
CardSide.back,
|
||||
CardField.back,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue