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:
Tizian 「ティツィアーン」 2026-10-01 18:42:25 +02:00
commit 0afc22d454
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 2701 additions and 735 deletions

View file

@ -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);
});
});
}

View file

@ -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();