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
13 changed files with 695 additions and 71 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.'),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue