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,
|
||||
);
|
||||
|
||||
/// 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),
|
||||
|
|
|
|||
|
|
@ -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>`.
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue