feat(pdf): cards beside a PDF are written in the composer's own editor

The sidebar wrote a front and a back only. It now holds the composer's
editor with a bloc of its own: every kind of card (multiple choice,
vocabulary with Look up, matching, a map, a character), tags, hints and
the explanation, pinning, duplicating and deleting. A new card is tied
to the page shown, which it can be moved to the current one, and waits
for its chapter's tag by name until it is created; text sent from the
pages goes into its front or its answer, as its kind keeps one. Create
saves it once it is complete, as the composer does; going back asks
before discarding what was written.

The screenshot of the pages has a button at the end of the format bar,
outside its scrolling, where an editor's place is for it
(InsertableBlock.inFormatBar); the Insert menu still lists it.

A new card no longer shows twice in the composer when its stored copy
arrives while it is saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 23:08:33 +02:00
commit fcee3c2670
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -595,4 +595,101 @@ void main() {
expect(await cards.cardsOf('japanese'), isEmpty);
expect(gateway.deletions, ['blank $neko', 'delete $neko']);
});
test('a card written from a PDF knows its page, waits for its chapter '
'tag by name, and takes text sent to its front and answer', () async {
start();
await loaded();
bloc
..add(
const CardComposerEventDraftAdded(
'new',
source: (pdfId: 'grammar', page: 5),
tagNames: ['Particles'],
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'は marks',
toAnswer: false,
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'the topic',
toAnswer: true,
),
);
final draft = (await until(
(s) => s.byId('new')?.content.back.plainText == 'the topic',
)).byId('new')!;
expect(draft.content.front.plainText, 'は marks');
expect(draft.content.source, (pdfId: 'grammar', page: 5));
expect(draft.newTagNames, ['Particles']);
bloc.add(const CardComposerEventLeft('new'));
final saved = await until(
(s) => s.byId('new')?.status == CardDraftStatus.saved,
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.content.source, (pdfId: 'grammar', page: 5));
expect((await tags.read()).of(stored.id).single.name, 'Particles');
// Held by its draft alone, never also among the stored cards.
expect(saved.items.where((d) => d.cardId == stored.id), hasLength(1));
});
test('text for the answer goes where the kind of card keeps it', () {
final base = card('猫', '');
expect(
base.withTextAdded('cat', toAnswer: true).back.plainText,
'cat',
);
final word = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.vocabulary,
meanings: const ['cat'],
);
expect(
word.withTextAdded('feline\nanimal', toAnswer: true).meanings,
['cat', 'feline animal'],
);
final choice = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.multipleChoice,
answers: [
CardAnswer(content: text('dog')),
CardAnswer(content: RichContent.empty(), correct: true),
],
);
expect(
choice.withTextAdded('cat', toAnswer: true).answers[1].content.plainText,
'cat',
);
});
test('discarding drops a new card, and takes a stored one back to how '
'it is stored', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged(neko, card('猫', 'kitten')))
..add(const CardComposerEventDiscarded('new'))
..add(CardComposerEventDiscarded(neko));
final state = await until(
(s) =>
s.byId('new') == null &&
s.byId(neko)?.content.back.plainText == 'cat',
);
expect(state.byId(neko)!.isDirty, isFalse);
expect(await cards.cardsOf('japanese'), hasLength(1));
});
}