feat(app): a character card for kanji and kana

One card type for both (card schema 7): the character on the front, its
meanings and a kanji's on and kun readings, each merging on its own. A
kana's romaji is its meaning, so kana are studied, and later written,
exactly as kanji are. The composer offers it beside the other kinds, each
kind of reading on one line (ショク、ジキ); studying shows the character
large and, with the answer, its meanings and readings (the other way round
when asked from the back); the card browser filters by it and finds a kun
reading without its okurigana dot; the Markdown export writes its meanings
and readings.

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-09 15:00:21 +02:00
commit 2fc4205010
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 765 additions and 18 deletions

View file

@ -181,7 +181,7 @@ void main() {
entry: (source: 'jmdict', id: '1358280'),
);
expect(cardSchemaVersion, 6);
expect(cardSchemaVersion, greaterThanOrEqualTo(6));
expect(decodeCardPayload(encodeCardPayload(content)), content);
expect(
decodeCardPayload(
@ -196,6 +196,39 @@ void main() {
);
});
test('a character keeps its meanings and its on and kun readings '
'(schema 7); a card from before has no readings', () {
final content = CardContent(
type: CardType.character,
front: text('食'),
back: RichContent.empty(),
meanings: const ['eat', 'food'],
onReadings: const ['ショク', 'ジキ'],
kunReadings: const ['く.う', 'た.べる'],
entry: (source: 'kanjidic', id: '食'),
);
expect(cardSchemaVersion, 7);
expect(decodeCardPayload(encodeCardPayload(content)), content);
expect(
decodePayload(
encodeCardPayload(content),
newestSchema: cardSchemaVersion,
),
containsPair('on', ['ショク', 'ジキ']),
);
final before = decodeCardPayload(
bytes({
'schemaVersion': 6,
'type': 'frontBack',
'front': text('猫').toJson(),
'back': text('cat').toJson(),
}),
);
expect(before.onReadings, isEmpty);
expect(before.kunReadings, isEmpty);
});
test('reads a report of a category it does not know as "other"', () {
final content = decodeCardPayload(
bytes({

View file

@ -74,6 +74,37 @@ void main() {
expect(word.withField(CardField.meanings, const [' ']).isComplete, false);
});
test('character: with the character and a meaning or a reading; a '
"kana's romaji is its meaning", () {
final kanji = CardContent(
type: CardType.character,
front: text('食'),
back: none,
onReadings: const ['ショク'],
);
final kana = CardContent(
type: CardType.character,
front: text('あ'),
back: none,
meanings: const ['a'],
);
expect(kanji.isComplete, true);
expect(kana.isComplete, true);
expect(
kanji.withField(CardField.onReadings, const <String>[]).isComplete,
false,
);
expect(kana.withField(CardField.front, none).isComplete, false);
expect(
kanji
.withField(CardField.onReadings, const <String>[])
.withField(CardField.front, none)
.isBlank,
true,
);
});
test('matching: with two targets and two items at least, every item in '
'a target and no target empty', () {
CardContent sorting(List<int> targets) => CardContent(
@ -120,6 +151,24 @@ void main() {
).answerPreview,
'Tokyo',
);
expect(
CardContent(
type: CardType.character,
front: text('食'),
back: none,
meanings: const ['eat', 'food'],
onReadings: const ['ショク', 'ジキ'],
kunReadings: const ['た.べる'],
).answerPreview,
'eat; food · ショク、ジキ · た.べる',
);
});
test("a character's readings, typed on one line, split at commas, "
'Japanese commas and spaces', () {
expect(splitReadings('ショク、ジキ'), ['ショク', 'ジキ']);
expect(splitReadings(' く.う, た.べる は.む '), ['く.う', 'た.べる', 'は.む']);
expect(splitReadings(''), isEmpty);
});
test('uses only the fields of its type', () {
@ -136,6 +185,15 @@ void main() {
).fields,
{CardField.front, CardField.answers, CardField.map},
);
expect(
CardContent(type: CardType.character, front: none, back: none).fields,
{
CardField.front,
CardField.meanings,
CardField.onReadings,
CardField.kunReadings,
},
);
});
test('normalized drops blank hints and a blank explanation, and takes a '

View file

@ -121,6 +121,40 @@ void main() {
expect(find.text('cat'), findsOneWidget);
});
testWidgets('a character shows its meanings and readings, and the filter '
'finds characters alone', (tester) async {
await cards.create(
'japanese',
CardContent(
type: CardType.character,
front: RichContent.plain('食'),
back: RichContent.empty(),
meanings: const ['eat'],
onReadings: const ['ショク'],
kunReadings: const ['た.べる'],
),
);
await show(tester);
final answer = find.text('eat\nOn: ショク\nKun: た.べる');
expect(answer, findsOneWidget);
await tester.tap(find.byTooltip('Filter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Characters only'));
await tester.tap(find.text('Apply'));
await tester.pumpAndSettle();
expect(find.text('cat'), findsNothing);
expect(answer, findsOneWidget);
// A kun reading is found without its okurigana dot.
await tester.tap(find.byTooltip('Search'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'taberu');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(answer, findsOneWidget);
});
testWidgets('cards chosen are deleted together, and Undo brings them '
'back', (tester) async {
await show(tester);

View file

@ -399,7 +399,7 @@ void main() {
await showStudyset(tester, size: const Size(412, 915));
await open(tester, '猫');
const types = ['Flashcard', 'Vocabulary', 'Matching', 'Map'];
const types = ['Flashcard', 'Vocabulary', 'Matching', 'Map', 'Character'];
for (final type in types) {
final name = tester.renderObject<RenderParagraph>(find.text(type));
expect(
@ -528,6 +528,31 @@ void main() {
expect(gateway.deletions, ['blank $id', 'delete $id']);
});
testWidgets('a character card takes its meanings and its on and kun '
'readings, each kind on one line', (tester) async {
await cards.create('japanese', card('食', 'food'));
await showStudyset(tester);
await open(tester, '食');
await tester.tap(find.text('Character'));
await settle(tester);
expect(find.text('On readings'), findsOneWidget);
expect(find.text('Kun readings'), findsOneWidget);
await tester.enterText(find.byType(TextField).at(0), 'eat');
await tester.enterText(find.byType(TextField).at(1), 'ショク、ジキ');
await tester.enterText(find.byType(TextField).at(2), 'く.う, た.べる');
await tester.tap(find.byType(BackButton));
await settle(tester);
final saved = (await cards.cardsOf('japanese')).single.content;
expect(saved.type, CardType.character);
expect(saved.front.plainText, '食');
expect(saved.meanings, ['eat']);
expect(saved.onReadings, ['ショク', 'ジキ']);
expect(saved.kunReadings, ['く.う', 'た.べる']);
expect(saved.back, RichContent.empty());
});
testWidgets('a map question takes the region tapped as its answer', (
tester,
) async {