feat(app): the card composer, for many cards in one sitting
Create flashcards, and tapping a card, open the composer: the stack of cards on the left (New flashcard on top, the new ones, then the stored ones; collapsible), the card being written on the right, saved as the person leaves it (another card, the pane, the screen, the app). The title says how saving stands; leaving with cards that couldn't be saved asks first. A card has its tags and their pin, an action menu (duplicate, clear, delete), the type switch (flashcard, vocabulary, matching), the front, and below a line its answer side: one answer or several to mark right or wrong, the reading and meanings, or categories and items. Fields changed differently on two devices show every version to keep one. The single-card editor goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a5515c13f7
commit
0afc22d454
2 changed files with 2701 additions and 735 deletions
|
|
@ -1,10 +1,7 @@
|
|||
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_edit_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_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.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';
|
||||
|
||||
|
|
@ -151,173 +148,4 @@ void main() {
|
|||
expect(state.shown, hasLength(3));
|
||||
});
|
||||
});
|
||||
|
||||
group('CardEditBloc', () {
|
||||
test('a new card can be saved once it has content, and then is '
|
||||
'done', () async {
|
||||
final bloc = CardEditBloc(cards, tags, 'japanese', null);
|
||||
addTearDown(bloc.close);
|
||||
expect(bloc.state.canSave, isFalse);
|
||||
|
||||
bloc.add(CardEditEventSideChanged(CardField.front, text('猫')));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
||||
);
|
||||
bloc.add(const CardEditEventSaveRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await cards.cardsOf('japanese')).single.content.front,
|
||||
text('猫'),
|
||||
);
|
||||
});
|
||||
|
||||
test('an existing card can be saved only when changed', () async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final bloc = CardEditBloc(
|
||||
cards,
|
||||
tags,
|
||||
'japanese',
|
||||
(await cards.cardsOf('japanese')).single,
|
||||
);
|
||||
addTearDown(bloc.close);
|
||||
expect(bloc.state.canSave, isFalse);
|
||||
|
||||
bloc.add(CardEditEventSideChanged(CardField.back, text('a cat')));
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
||||
);
|
||||
});
|
||||
|
||||
group('with a card changed differently on two devices', () {
|
||||
late Card conflicted;
|
||||
late CardEditBloc bloc;
|
||||
|
||||
setUp(() async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final created = (await cards.cardsOf('japanese')).single;
|
||||
await cards.save(created, card('猫', 'a cat'));
|
||||
gateway.receiveRemoteRevision(
|
||||
id: 'rev-9000',
|
||||
cardId: created.id,
|
||||
parents: created.heads,
|
||||
payload: encodeCardPayload(card('猫', 'the cat')),
|
||||
);
|
||||
conflicted = (await cards.cardsOf('japanese')).single;
|
||||
bloc = CardEditBloc(cards, tags, 'japanese', conflicted);
|
||||
});
|
||||
tearDown(() => bloc.close());
|
||||
|
||||
test('waits for a version of every conflicted side', () {
|
||||
expect(bloc.state.unresolvedSides, {CardField.back});
|
||||
expect(bloc.state.canSave, isFalse);
|
||||
});
|
||||
|
||||
test('choosing a version resolves the side; saving resolves the '
|
||||
'card', () async {
|
||||
bloc.add(CardEditEventVersionChosen(CardField.back, text('a cat')));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(
|
||||
isA<CardEditState>()
|
||||
.having((s) => s.unresolvedSides, 'unresolved', isEmpty)
|
||||
.having((s) => s.canSave, 'can save', true),
|
||||
),
|
||||
);
|
||||
|
||||
bloc.add(const CardEditEventSaveRequested());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
|
||||
);
|
||||
|
||||
final resolved = (await cards.cardsOf('japanese')).single;
|
||||
expect(resolved.hasConflict, isFalse);
|
||||
expect(resolved.content.back, text('a cat'));
|
||||
});
|
||||
});
|
||||
|
||||
test("shows a card's tags; changing only them can be saved, without a "
|
||||
'new revision of its sides', () async {
|
||||
final cardId = await cards.create('japanese', card('猫', 'cat'));
|
||||
final noun = await tags.create('noun');
|
||||
final animal = await tags.create('animal');
|
||||
await tags.setTagsOf(cardId, {noun});
|
||||
final bloc = CardEditBloc(
|
||||
cards,
|
||||
tags,
|
||||
'japanese',
|
||||
(await cards.cardsOf('japanese')).single,
|
||||
)..add(const CardEditEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
final loaded = await bloc.stream.firstWhere((s) => s.tagIds != null);
|
||||
expect(loaded.tagIds, {noun});
|
||||
expect(loaded.canSave, isFalse);
|
||||
|
||||
bloc.add(CardEditEventTagsChanged({noun, animal}));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(isA<CardEditState>().having((s) => s.canSave, 'can save', true)),
|
||||
);
|
||||
bloc.add(const CardEditEventSaveRequested());
|
||||
await bloc.stream.firstWhere((s) => s.isDone);
|
||||
|
||||
final tagging = await tags.read();
|
||||
expect(tagging.of(cardId).map((tag) => tag.name), ['animal', 'noun']);
|
||||
expect(await gateway.revisionsOf({cardId}), hasLength(1));
|
||||
});
|
||||
|
||||
test('a new card gets the tags chosen once saved', () async {
|
||||
final noun = await tags.create('noun');
|
||||
final bloc = CardEditBloc(cards, tags, 'japanese', null)
|
||||
..add(const CardEditEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await bloc.stream.firstWhere((s) => s.tagIds != null);
|
||||
|
||||
bloc
|
||||
..add(CardEditEventTagsChanged({noun}))
|
||||
..add(CardEditEventSideChanged(CardField.front, text('猫')))
|
||||
..add(const CardEditEventSaveRequested());
|
||||
await bloc.stream.firstWhere((s) => s.isDone);
|
||||
|
||||
final cardId = (await cards.cardsOf('japanese')).single.id;
|
||||
expect((await tags.read()).of(cardId).map((tag) => tag.id), [noun]);
|
||||
});
|
||||
|
||||
test('a new card with only tags has nothing to save', () async {
|
||||
final noun = await tags.create('noun');
|
||||
final bloc = CardEditBloc(cards, tags, 'japanese', null)
|
||||
..add(const CardEditEventStarted());
|
||||
addTearDown(bloc.close);
|
||||
await bloc.stream.firstWhere((s) => s.tagIds != null);
|
||||
|
||||
bloc.add(CardEditEventTagsChanged({noun}));
|
||||
final state = await bloc.stream.first;
|
||||
|
||||
expect(state.canSave, isFalse);
|
||||
});
|
||||
|
||||
test('deleting the card is done too', () async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
final bloc = CardEditBloc(
|
||||
cards,
|
||||
tags,
|
||||
'japanese',
|
||||
(await cards.cardsOf('japanese')).single,
|
||||
)..add(const CardEditEventDeleteRequested());
|
||||
addTearDown(bloc.close);
|
||||
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emits(isA<CardEditState>().having((s) => s.isDone, 'done', true)),
|
||||
);
|
||||
expect(await cards.cardsOf('japanese'), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,21 @@ void main() {
|
|||
expect((await tags.read()).cardsWith(noun), {neko});
|
||||
});
|
||||
|
||||
test('an incomplete one still saves its tags, and only them', () async {
|
||||
final neko = await cards.create('japanese', card('猫', ''));
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue