feat(app): the card composer's state: many cards, saved on leaving

A bloc holding a studyset's cards as drafts, new ones on top and the
stored ones below. A card is saved when the person leaves it, once it
changed and has everything its type asks for, only its type's fields
stored; a failed save waits for the next leaving. A new card takes the
type of the card before it and what is pinned of it (front, answer,
tags); duplicating, clearing and deleting; a card devices changed
differently first needs a version chosen for every field in conflict.

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:21:54 +02:00
commit a5515c13f7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1079 additions and 0 deletions

View file

@ -0,0 +1,675 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
/// Writes a studyset's cards, many in one sitting: new cards on top, the
/// stored ones below, each edited in place. A card is saved when the person
/// leaves it, once it has changed and has everything its type asks for; a
/// card devices changed differently first needs a version chosen for every
/// field in conflict.
class CardComposerBloc(
final CardRepository _cards,
final TagRepository _tags,
final String _studysetId,
) extends Bloc<CardComposerEvent, CardComposerState> {
this : super(const CardComposerState()) {
on<CardComposerEventStarted>(_onStarted);
on<CardComposerEventDraftAdded>(_onDraftAdded);
on<CardComposerEventChanged>(
(event, emit) => _update(
emit,
event.id,
(draft) => draft.copyWith(content: event.content),
),
);
on<CardComposerEventTagsChanged>(
(event, emit) => _update(
emit,
event.id,
(draft) => draft.copyWith(tagIds: event.tagIds),
),
);
on<CardComposerEventVersionChosen>(
(event, emit) => _update(
emit,
event.id,
(draft) => draft.copyWith(
content: draft.content.withField(event.field, event.version),
resolved: {...draft.resolved, event.field},
),
),
);
on<CardComposerEventCleared>(
(event, emit) => _update(
emit,
event.id,
(draft) => draft.copyWith(
content: CardContent.empty().withField(
CardField.type,
draft.content.type,
),
),
),
);
on<CardComposerEventDuplicated>(_onDuplicated);
on<CardComposerEventLeft>(_onLeft);
on<CardComposerEventDeleted>(_onDeleted);
on<CardComposerEventPinToggled>(
(event, emit) => emit(
state.copyWith(
pins: state.pins.contains(event.pin)
? ({...state.pins}..remove(event.pin))
: {...state.pins, event.pin},
),
),
);
}
/// The stored cards, by id, as last read.
var _stored = <String, Card>{};
Future<void> _onStarted(
CardComposerEventStarted event,
Emitter<CardComposerState> emit,
) async {
await Future.wait([
emit.forEach(
_cards.watch(_studysetId),
onData: (cards) {
_stored = {for (final card in cards) card.id: card};
return _withStored(cards);
},
onError: (error, _) {
log('Reading cards failed', name: 'Composer', error: error);
return state;
},
),
emit.forEach(
_tags.watch(),
onData: _withTagging,
// Without tags the cards can still be written.
onError: (_, _) => state,
),
]);
}
/// The state with the stored [cards]: drafts of cards not yet in it are
/// added, those gone (deleted, here or elsewhere) dropped, and the others
/// take the stored version as their baseline, and as their content unless
/// the person changed them.
CardComposerState _withStored(List<Card> cards) {
final byId = {for (final draft in state.items) draft.cardId: draft};
final saved = {
for (final draft in state.drafts)
if (draft.cardId != null) draft.cardId,
};
CardDraft updated(CardDraft? draft, Card card) {
if (draft == null) {
final tagIds = {
for (final tag in state.tagging.of(card.id)) tag.id,
};
return CardDraft(
id: card.id,
cardId: card.id,
content: card.content,
saved: card.content,
tagIds: tagIds,
savedTagIds: tagIds,
conflicts: card.conflicts,
);
}
return draft.copyWith(
saved: card.content,
content: draft.isDirty ? null : card.content,
conflicts: card.conflicts,
);
}
final stored = {for (final card in cards) card.id};
return state.copyWith(
loaded: true,
drafts: [
for (final draft in state.drafts)
if (draft.cardId == null || stored.contains(draft.cardId))
switch (draft.cardId) {
final id? => updated(draft, _stored[id]!),
null => draft,
},
],
existing: [
for (final card in cards)
if (!saved.contains(card.id)) updated(byId[card.id], card),
],
);
}
/// The state with the person's [tagging]: cards the person hasn't given
/// other tags take their stored ones.
CardComposerState _withTagging(Tagging tagging) {
CardDraft updated(CardDraft draft) {
final id = draft.cardId;
if (id == null) {
return draft;
}
final stored = {for (final tag in tagging.of(id)) tag.id};
final unchanged = _sameSet(draft.tagIds, draft.savedTagIds);
return draft.copyWith(
savedTagIds: stored,
tagIds: unchanged ? stored : null,
);
}
return state.copyWith(
tagging: tagging,
drafts: state.drafts.map(updated).toList(),
existing: state.existing.map(updated).toList(),
);
}
void _onDraftAdded(
CardComposerEventDraftAdded event,
Emitter<CardComposerState> emit,
) {
// A new card takes the type of the one written before, and what is
// pinned of it, as Anki's sticky fields do.
final previous = state.byId(event.after);
var content = CardContent.empty();
var tagIds = <String>{};
if (previous != null) {
final from = previous.content;
content = content.withField(CardField.type, from.type);
if (state.pins.contains(CardPin.front)) {
content = content.withField(CardField.front, from.front);
}
if (state.pins.contains(CardPin.answer)) {
for (final field in [
CardField.back,
CardField.answers,
CardField.reading,
CardField.meanings,
CardField.matching,
CardField.map,
]) {
content = content.withField(field, from[field]);
}
}
if (state.pins.contains(CardPin.tags)) {
tagIds = {...previous.tagIds};
}
}
emit(
state.copyWith(
drafts: [
_withStatus(
CardDraft(
id: event.id,
content: content,
saved: CardContent.empty(),
tagIds: tagIds,
savedTagIds: const {},
),
),
...state.drafts,
],
),
);
}
void _onDuplicated(
CardComposerEventDuplicated event,
Emitter<CardComposerState> emit,
) {
final source = state.byId(event.sourceId);
if (source == null) {
return;
}
emit(
state.copyWith(
drafts: [
_withStatus(
CardDraft(
id: event.newId,
content: source.content,
saved: CardContent.empty(),
tagIds: source.tagIds,
savedTagIds: const {},
),
),
...state.drafts,
],
),
);
}
Future<void> _onLeft(
CardComposerEventLeft event,
Emitter<CardComposerState> emit,
) async {
final draft = state.byId(event.id);
if (draft == null ||
draft.status == CardDraftStatus.saving ||
!draft.canSave) {
return;
}
final content = draft.content.normalized;
final tagIds = draft.tagIds;
_update(emit, draft.id, (d) => d.copyWith(status: CardDraftStatus.saving));
try {
final String cardId;
if (draft.cardId case final id?) {
cardId = id;
final card = _stored[id];
if (card != null &&
(content != draft.saved.normalized || card.hasConflict)) {
await _cards.save(card, content);
}
} else {
cardId = await _cards.create(_studysetId, content);
}
if (!_sameSet(tagIds, draft.savedTagIds)) {
await _tags.setTagsOf(cardId, tagIds);
}
_update(
emit,
draft.id,
(d) => d.copyWith(
cardId: cardId,
saved: content,
savedTagIds: tagIds,
resolved: const {},
savedHere: true,
// Changed again while saving, it waits for the next leaving.
status: d.content.normalized == content && _sameSet(d.tagIds, tagIds)
? CardDraftStatus.saved
: CardDraftStatus.dirty,
),
);
} on Object catch (error) {
log('Saving a card failed', name: 'Composer', error: error);
_update(
emit,
draft.id,
(d) => d.copyWith(status: CardDraftStatus.failed),
);
}
}
Future<void> _onDeleted(
CardComposerEventDeleted event,
Emitter<CardComposerState> emit,
) async {
final draft = state.byId(event.id);
if (draft == null) {
return;
}
emit(
state.copyWith(
drafts: [
for (final each in state.drafts)
if (each.id != draft.id) each,
],
existing: [
for (final each in state.existing)
if (each.id != draft.id) each,
],
),
);
if (_stored[draft.cardId] case final card?) {
await _cards.delete(card);
}
}
/// Replaces the draft [id] with what [change] makes of it, and works out
/// its status anew unless [change] set one.
void _update(
Emitter<CardComposerState> emit,
String id,
CardDraft Function(CardDraft draft) change,
) {
CardDraft apply(CardDraft draft) {
if (draft.id != id) {
return draft;
}
final changed = change(draft);
return changed.status != draft.status ? changed : _withStatus(changed);
}
emit(
state.copyWith(
drafts: state.drafts.map(apply).toList(),
existing: state.existing.map(apply).toList(),
),
);
}
}
/// [draft] with its status worked out from its content, but while saving.
CardDraft _withStatus(CardDraft draft) => draft.copyWith(
status: switch (draft.status) {
CardDraftStatus.saving => CardDraftStatus.saving,
_ when draft.isDirty => CardDraftStatus.dirty,
_ when draft.savedHere => CardDraftStatus.saved,
_ => CardDraftStatus.clean,
},
);
bool _sameSet(Set<String> a, Set<String> b) =>
a.length == b.length && a.containsAll(b);
/// How a card in the composer stands.
enum CardDraftStatus() {
/// Not changed: new and untouched, or as stored.
clean,
/// Changed and not saved yet: incomplete, or not left yet.
dirty,
/// Being saved.
saving,
/// Saved as it is now.
saved,
/// Saving failed; leaving it again tries anew.
failed,
}
/// What a new card takes over from the card written before it.
enum CardPin() {
/// The front.
front,
/// The answer: the back, the answers, the reading and meanings, the
/// matching, the map.
answer,
/// The tags.
tags,
}
/// A card as the composer holds it: a new one or a stored one, with the
/// person's changes.
final class const CardDraft({
/// The draft's own id: a stored card's id, or one made for a new card.
required final String id,
/// The content as the person has it now.
required final CardContent content,
/// The content as stored; empty for a new card.
required final CardContent saved,
/// The ids of the tags the person gave it.
required final Set<String> tagIds,
/// The ids of the tags it has as stored.
required final Set<String> savedTagIds,
/// The stored card's id; null until a new card is saved.
final String? cardId,
/// How it stands.
final CardDraftStatus status = CardDraftStatus.clean,
/// The fields devices changed differently, with every version.
final Map<CardField, List<Object?>> conflicts = const {},
/// The fields in conflict the person chose a version for.
final Set<CardField> resolved = const {},
/// Whether it was saved in this sitting.
final bool savedHere = false,
}) with Equatable {
/// The fields in conflict still waiting for a version.
Set<CardField> get unresolved => conflicts.keys.toSet().difference(resolved);
/// Whether it differs from what is stored, or settles a conflict; a new
/// card, whether it holds anything, whatever its type.
bool get isDirty => switch (cardId) {
null => !content.isBlank || tagIds.isNotEmpty,
_ =>
content.normalized != saved.normalized ||
!_sameSet(tagIds, savedTagIds) ||
(conflicts.isNotEmpty && unresolved.isEmpty),
};
/// Whether leaving it saves it: changed, complete, and every conflict
/// settled.
bool get canSave => isDirty && content.isComplete && unresolved.isEmpty;
/// This draft with some fields replaced.
CardDraft copyWith({
CardContent? content,
CardContent? saved,
Set<String>? tagIds,
Set<String>? savedTagIds,
String? cardId,
CardDraftStatus? status,
Map<CardField, List<Object?>>? conflicts,
Set<CardField>? resolved,
bool? savedHere,
}) => CardDraft(
id: id,
content: content ?? this.content,
saved: saved ?? this.saved,
tagIds: tagIds ?? this.tagIds,
savedTagIds: savedTagIds ?? this.savedTagIds,
cardId: cardId ?? this.cardId,
status: status ?? this.status,
conflicts: conflicts ?? this.conflicts,
resolved: resolved ?? this.resolved,
savedHere: savedHere ?? this.savedHere,
);
@override
List<Object?> get props => [
id,
content,
saved,
tagIds,
savedTagIds,
cardId,
status,
conflicts,
resolved,
savedHere,
];
}
/// How the composer's saving stands as a whole, for its title.
enum CardComposerSaving() {
/// Nothing saved yet in this sitting.
idle,
/// A card is being saved.
saving,
/// A card's last save failed.
failed,
/// Everything is saved.
saved,
}
/// The composer: the new cards, newest first, then the stored ones in
/// order.
final class const CardComposerState({
/// The new cards of this sitting, newest first.
final List<CardDraft> drafts = const [],
/// The stored cards, in order, but for those [drafts] hold.
final List<CardDraft> existing = const [],
/// Whether the stored cards were read.
final bool loaded = false,
/// The person's tags.
final Tagging tagging = Tagging.empty,
/// What new cards take over from the card written before.
final Set<CardPin> pins = const {},
}) with Equatable {
/// Every card, as the stack shows them.
List<CardDraft> get items => [...drafts, ...existing];
/// The draft [id], or null.
CardDraft? byId(String? id) =>
items.where((draft) => draft.id == id).firstOrNull;
/// The cards that leaving the composer now would lose: changed but not
/// saved, incomplete or failed.
int get unsaved => items
.where(
(draft) =>
draft.status == CardDraftStatus.failed ||
(draft.isDirty && draft.status != CardDraftStatus.saving),
)
.length;
/// How saving stands as a whole.
CardComposerSaving get saving {
if (items.any((draft) => draft.status == CardDraftStatus.saving)) {
return CardComposerSaving.saving;
}
if (items.any((draft) => draft.status == CardDraftStatus.failed)) {
return CardComposerSaving.failed;
}
if (items.any((draft) => draft.status == CardDraftStatus.saved)) {
return CardComposerSaving.saved;
}
return CardComposerSaving.idle;
}
/// This state with some fields replaced.
CardComposerState copyWith({
List<CardDraft>? drafts,
List<CardDraft>? existing,
bool? loaded,
Tagging? tagging,
Set<CardPin>? pins,
}) => CardComposerState(
drafts: drafts ?? this.drafts,
existing: existing ?? this.existing,
loaded: loaded ?? this.loaded,
tagging: tagging ?? this.tagging,
pins: pins ?? this.pins,
);
@override
List<Object?> get props => [drafts, existing, loaded, tagging, pins];
}
/// What happens in the composer.
sealed class const CardComposerEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The composer opens: follow the studyset's cards and the tags.
final class const CardComposerEventStarted() extends CardComposerEvent;
/// Add a new card [id] on top, after the card [after] the person was on.
final class const CardComposerEventDraftAdded(
/// The new card's draft id.
final String id, {
/// The draft the person was on, whose type and pinned fields it takes.
final String? after,
}) extends CardComposerEvent {
@override
List<Object?> get props => [id, after];
}
/// The person changed the card [id] to [content].
final class const CardComposerEventChanged(
/// The draft's id.
final String id,
/// Its content now.
final CardContent content,
) extends CardComposerEvent {
@override
List<Object?> get props => [id, content];
}
/// The person chose the tags [tagIds] for the card [id].
final class const CardComposerEventTagsChanged(
/// The draft's id.
final String id,
/// The ids of the tags chosen.
final Set<String> tagIds,
) extends CardComposerEvent {
@override
List<Object?> get props => [id, tagIds];
}
/// For a field devices changed differently, the person keeps [version].
final class const CardComposerEventVersionChosen(
/// The draft's id.
final String id,
/// The field in conflict.
final CardField field,
/// The version to keep.
final Object? version,
) extends CardComposerEvent {
@override
List<Object?> get props => [id, field, version];
}
/// Empty the card [id]'s fields, keeping its type.
final class const CardComposerEventCleared(
/// The draft's id.
final String id,
) extends CardComposerEvent {
@override
List<Object?> get props => [id];
}
/// Copy the card [sourceId] into a new card [newId] on top.
final class const CardComposerEventDuplicated(
/// The draft copied.
final String sourceId,
/// The copy's draft id.
final String newId,
) extends CardComposerEvent {
@override
List<Object?> get props => [sourceId, newId];
}
/// The person left the card [id]: save it if it can be.
final class const CardComposerEventLeft(
/// The draft's id.
final String id,
) extends CardComposerEvent {
@override
List<Object?> get props => [id];
}
/// Delete the card [id]; the person has confirmed it where it mattered.
final class const CardComposerEventDeleted(
/// The draft's id.
final String id,
) extends CardComposerEvent {
@override
List<Object?> get props => [id];
}
/// Pin [pin] for new cards, or unpin it.
final class const CardComposerEventPinToggled(
/// What to pin.
final CardPin pin,
) extends CardComposerEvent {
@override
List<Object?> get props => [pin];
}

View file

@ -142,6 +142,27 @@ final class const CardContent({
map: field == CardField.map ? value as String? : map,
);
/// This content with some fields replaced; [withField] clears the map.
CardContent copyWith({
CardType? type,
RichContent? front,
RichContent? back,
List<CardAnswer>? answers,
String? reading,
List<String>? meanings,
Matching? matching,
String? map,
}) => CardContent(
type: type ?? this.type,
front: front ?? this.front,
back: back ?? this.back,
answers: answers ?? this.answers,
reading: reading ?? this.reading,
meanings: meanings ?? this.meanings,
matching: matching ?? this.matching,
map: map ?? this.map,
);
/// The fields [type] uses; the others stay empty.
Set<CardField> get fields => switch (type) {
CardType.frontBack => const {CardField.front, CardField.back},
@ -175,6 +196,24 @@ final class const CardContent({
return content;
}
/// Whether none of its type's fields holds anything.
bool get isBlank => switch (type) {
CardType.frontBack => _blank(front) && _blank(back),
CardType.multipleChoice =>
_blank(front) &&
answers.every(
(answer) => _blank(answer.content) && answer.region == null,
),
CardType.vocabulary =>
_blank(front) &&
reading.trim().isEmpty &&
meanings.every((meaning) => meaning.trim().isEmpty),
CardType.matching =>
_blank(front) &&
matching.targets.every((target) => target.trim().isEmpty) &&
matching.items.every((item) => item.text.trim().isEmpty),
};
/// Whether the card can be studied: everything its type asks for is there.
bool get isComplete => switch (type) {
CardType.frontBack => !_blank(front) && !_blank(back),

View file

@ -0,0 +1,356 @@
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/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
RichContent text(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
void main() {
late FakeCardGateway gateway;
late CardRepository cards;
late TagRepository tags;
late CardComposerBloc bloc;
setUp(() {
gateway = FakeCardGateway();
cards = CardRepository(gateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
});
tearDown(() async {
await bloc.close();
await tags.dispose();
await cards.dispose();
});
void start() =>
bloc = CardComposerBloc(cards, tags, 'japanese')
..add(const CardComposerEventStarted());
/// The next state (or the current one) [test] holds for.
Future<CardComposerState> until(bool Function(CardComposerState) test) {
if (test(bloc.state)) {
return Future.value(bloc.state);
}
return bloc.stream.firstWhere(test);
}
Future<CardComposerState> loaded() => until((s) => s.loaded);
test('holds the stored cards in order, with their tags', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
await cards.create('japanese', card('犬', 'dog'));
final noun = await tags.create('noun');
await tags.setTagsOf(neko, {noun});
start();
final state = await until(
(s) => s.loaded && (s.byId(neko)?.tagIds.isNotEmpty ?? false),
);
expect(
[for (final draft in state.items) draft.content.front],
[
text('猫'),
text('犬'),
],
);
expect(state.byId(neko)!.tagIds, {noun});
expect(state.byId(neko)!.status, CardDraftStatus.clean);
});
group('A new card', () {
test('goes on top, and is saved once left complete, then held only '
'once', () async {
await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged('new', card('犬', '')));
final incomplete = await until(
(s) => s.byId('new')?.status == CardDraftStatus.dirty,
);
expect(incomplete.items.first.id, 'new');
bloc.add(const CardComposerEventLeft('new'));
expect(gateway.written, hasLength(1));
bloc
..add(CardComposerEventChanged('new', card('犬', 'dog')))
..add(const CardComposerEventLeft('new'));
final saved = await until(
(s) =>
s.byId('new')?.status == CardDraftStatus.saved &&
s.existing.length == 1,
);
expect(gateway.written, hasLength(2));
expect(saved.items, hasLength(2));
expect(saved.saving, CardComposerSaving.saved);
expect((await cards.cardsOf('japanese')).last.content, card('犬', 'dog'));
});
test('keeps only the fields of its type when saved', () async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventChanged(
'new',
card('食べる', 'leftover back').copyWith(
type: CardType.vocabulary,
meanings: const ['to eat'],
),
),
)
..add(const CardComposerEventLeft('new'));
await until((s) => s.byId('new')?.status == CardDraftStatus.saved);
final stored = decodeCardPayload(gateway.written.single.payload);
expect(stored.back, RichContent.empty());
expect(stored.meanings, ['to eat']);
});
test('takes the type of the card before it, and what is pinned', () async {
final noun = await tags.create('noun');
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('first'))
..add(
CardComposerEventChanged(
'first',
card('食べる', 'to eat').copyWith(type: CardType.vocabulary),
),
)
..add(CardComposerEventTagsChanged('first', {noun}))
..add(const CardComposerEventPinToggled(CardPin.tags))
..add(const CardComposerEventDraftAdded('second', after: 'first'));
final state = await until((s) => s.byId('second') != null);
final second = state.byId('second')!;
expect(second.content.type, CardType.vocabulary);
expect(second.content.front, RichContent.empty());
expect(second.tagIds, {noun});
expect(state.items.first.id, 'second');
});
test(
'an untouched one is clean and nothing to lose, of any type',
() async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventChanged(
'new',
CardContent.empty().copyWith(type: CardType.vocabulary),
),
);
final state = await until(
(s) => s.byId('new')?.content.type == CardType.vocabulary,
);
expect(state.byId('new')!.status, CardDraftStatus.clean);
expect(state.unsaved, 0);
},
);
});
group('A stored card', () {
test('saves an edit as a new revision once left', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(CardComposerEventChanged(neko, card('猫', 'a cat')))
..add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.status == CardDraftStatus.saved);
expect(gateway.written, hasLength(2));
expect(
(await cards.cardsOf('japanese')).single.content,
card('猫', 'a cat'),
);
});
test('left unchanged, stores nothing', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc.add(CardComposerEventLeft(neko));
await Future<void>.delayed(Duration.zero);
expect(gateway.written, hasLength(1));
});
test('with only its tags changed, stores them and no revision', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
final noun = await tags.create('noun');
start();
await loaded();
bloc
..add(CardComposerEventTagsChanged(neko, {noun}))
..add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.status == CardDraftStatus.saved);
expect(gateway.written, hasLength(1));
expect((await tags.read()).cardsWith(noun), {neko});
});
test('a failed save says so, and leaving again tries anew', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
gateway.failWrites = Exception('disk');
bloc
..add(CardComposerEventChanged(neko, card('猫', 'a cat')))
..add(CardComposerEventLeft(neko));
final failed = await until(
(s) => s.byId(neko)?.status == CardDraftStatus.failed,
);
expect(failed.saving, CardComposerSaving.failed);
expect(failed.unsaved, 1);
gateway.failWrites = null;
bloc.add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.status == CardDraftStatus.saved);
});
test('changed differently on two devices waits for a version of every '
'field in conflict, and saving settles it', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
final stored = (await cards.cardsOf('japanese')).single;
await cards.save(stored, card('猫', 'a cat'));
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'the cat')),
);
start();
final conflicted = await until(
(s) => s.byId(neko)?.conflicts.isNotEmpty ?? false,
);
expect(conflicted.byId(neko)!.unresolved, {CardField.back});
expect(conflicted.byId(neko)!.canSave, isFalse);
bloc
..add(
CardComposerEventVersionChosen(neko, CardField.back, text('a cat')),
)
..add(CardComposerEventLeft(neko));
await until(
(s) =>
s.byId(neko)?.status == CardDraftStatus.saved &&
s.byId(neko)!.conflicts.isEmpty,
);
final resolved = (await cards.cardsOf('japanese')).single;
expect(resolved.hasConflict, isFalse);
expect(resolved.content.back, text('a cat'));
});
test('takes changes from another device unless the person changed '
'it', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
final state = await until(
(s) => s.byId(neko)?.content == card('猫', 'kitty'),
);
expect(state.byId(neko)!.status, CardDraftStatus.clean);
});
});
test('a duplicate copies content and tags as a new card', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc.add(CardComposerEventDuplicated(neko, 'copy'));
final state = await until((s) => s.byId('copy') != null);
expect(state.items.first.id, 'copy');
expect(state.byId('copy')!.content, card('猫', 'cat'));
expect(state.byId('copy')!.status, CardDraftStatus.dirty);
});
test('clearing empties the fields and keeps the type', () async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(
CardComposerEventChanged(
'new',
card('食べる', '').copyWith(type: CardType.vocabulary),
),
)
..add(const CardComposerEventCleared('new'));
final state = await until(
(s) =>
s.byId('new')?.content.type == CardType.vocabulary &&
s.byId('new')!.content.front == RichContent.empty(),
);
expect(state.byId('new')!.status, CardDraftStatus.clean);
});
test('deleting drops a new card, and deletes a stored one', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(const CardComposerEventDeleted('new'))
..add(CardComposerEventDeleted(neko));
await until((s) => s.items.isEmpty);
expect(await cards.cardsOf('japanese'), isEmpty);
expect(gateway.deletions, ['blank $neko', 'delete $neko']);
});
}

View file

@ -19,6 +19,9 @@ class FakeCardGateway() implements CardGateway {
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
final deletions = <String>[];
/// When set, storing a card or a revision fails with it.
Exception? failWrites;
/// The payload each revision holds now, by revision id.
Map<String, ByteData> get payloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
@ -59,6 +62,9 @@ class FakeCardGateway() implements CardGateway {
required String position,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final id = _newId('card');
_cards[id] = (studysetId: studysetId, position: position);
await insertRevision(cardId: id, parents: const {}, payload: payload);
@ -71,6 +77,9 @@ class FakeCardGateway() implements CardGateway {
required Set<String> parents,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final revision = (
id: _newId('rev'),
cardId: cardId,