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:
parent
927017bf15
commit
a5515c13f7
4 changed files with 1079 additions and 0 deletions
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -142,6 +142,27 @@ final class const CardContent({
|
||||||
map: field == CardField.map ? value as String? : map,
|
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.
|
/// The fields [type] uses; the others stay empty.
|
||||||
Set<CardField> get fields => switch (type) {
|
Set<CardField> get fields => switch (type) {
|
||||||
CardType.frontBack => const {CardField.front, CardField.back},
|
CardType.frontBack => const {CardField.front, CardField.back},
|
||||||
|
|
@ -175,6 +196,24 @@ final class const CardContent({
|
||||||
return content;
|
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.
|
/// Whether the card can be studied: everything its type asks for is there.
|
||||||
bool get isComplete => switch (type) {
|
bool get isComplete => switch (type) {
|
||||||
CardType.frontBack => !_blank(front) && !_blank(back),
|
CardType.frontBack => !_blank(front) && !_blank(back),
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,9 @@ class FakeCardGateway() implements CardGateway {
|
||||||
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
|
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
|
||||||
final deletions = <String>[];
|
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.
|
/// The payload each revision holds now, by revision id.
|
||||||
Map<String, ByteData> get payloads => {
|
Map<String, ByteData> get payloads => {
|
||||||
for (final revision in _revisions.values) revision.id: revision.payload,
|
for (final revision in _revisions.values) revision.id: revision.payload,
|
||||||
|
|
@ -59,6 +62,9 @@ class FakeCardGateway() implements CardGateway {
|
||||||
required String position,
|
required String position,
|
||||||
required ByteData payload,
|
required ByteData payload,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (failWrites case final error?) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
final id = _newId('card');
|
final id = _newId('card');
|
||||||
_cards[id] = (studysetId: studysetId, position: position);
|
_cards[id] = (studysetId: studysetId, position: position);
|
||||||
await insertRevision(cardId: id, parents: const {}, payload: payload);
|
await insertRevision(cardId: id, parents: const {}, payload: payload);
|
||||||
|
|
@ -71,6 +77,9 @@ class FakeCardGateway() implements CardGateway {
|
||||||
required Set<String> parents,
|
required Set<String> parents,
|
||||||
required ByteData payload,
|
required ByteData payload,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (failWrites case final error?) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
final revision = (
|
final revision = (
|
||||||
id: _newId('rev'),
|
id: _newId('rev'),
|
||||||
cardId: cardId,
|
cardId: cardId,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue