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
20 changed files with 765 additions and 18 deletions

View file

@ -567,16 +567,19 @@
"composerKindVocabulary": "Vokabel",
"composerKindMatching": "Zuordnung",
"composerKindMap": "Landkarte",
"composerKindCharacter": "Schriftzeichen",
"composerLabelFront": "Vorderseite",
"composerLabelQuestion": "Frage",
"composerLabelExpression": "Ausdruck",
"composerLabelPrompt": "Aufgabe",
"composerLabelCharacter": "Schriftzeichen",
"composerLabelBack": "Rückseite",
"composerLabelAnswers": "Antworten",
"composerLabelAnswer": "Antwort",
"composerPlaceholderFront": "Frage hinzufügen",
"composerPlaceholderExpression": "Ausdruck hinzufügen",
"composerPlaceholderPrompt": "Aufgabe hinzufügen",
"composerPlaceholderCharacter": "Kanji oder Kana hinzufügen",
"composerAddAnswer": "Antwort hinzufügen",
"composerCorrectAnswer": "Richtige Antwort",
"composerWrongAnswer": "Falsche Antwort",
@ -623,6 +626,13 @@
"vocabularyRemoveGloss": "Übersetzung entfernen",
"vocabularyAddSense": "Wortbedeutung hinzufügen",
"vocabularyRemoveSense": "Wortbedeutung entfernen",
"characterPrimaryMeaningHint": "Hauptbedeutung, bei Kana die Umschrift",
"characterOnReadings": "On-Lesungen",
"characterOnReadingsHint": "Aus dem Chinesischen, in Katakana: ショク、ジキ",
"characterKunReadings": "Kun-Lesungen",
"characterKunReadingsHint": "Japanisch, in Hiragana, Okurigana nach einem Punkt: た.べる",
"characterOn": "On",
"characterKun": "Kun",
"matchingCategories": "Kategorien",
"matchingCategoriesHint": "Die Ablagekarten, auf die Lernende Elemente ziehen. Jede Kategorie braucht mindestens ein Element, daher kann eine Karte nicht mehr Kategorien als Elemente haben.",
"matchingCategory": "Kategorie {number}",
@ -1213,6 +1223,7 @@
"cardBrowserTypeVocabulary": "Nur Wörter",
"cardBrowserTypeMatching": "Nur Zuordnungen",
"cardBrowserTypeMap": "Nur Karten",
"cardBrowserTypeCharacter": "Nur Schriftzeichen",
"cardBrowserFilterTag": "Tag",
"cardBrowserAllTags": "Alle Tags",
"cardBrowserFilterStatus": "Stand",

View file

@ -625,16 +625,19 @@
"composerKindVocabulary": "Vocabulary",
"composerKindMatching": "Matching",
"composerKindMap": "Map",
"composerKindCharacter": "Character",
"composerLabelFront": "Front",
"composerLabelQuestion": "Question",
"composerLabelExpression": "Expression",
"composerLabelPrompt": "Prompt",
"composerLabelCharacter": "Character",
"composerLabelBack": "Back",
"composerLabelAnswers": "Answers",
"composerLabelAnswer": "Answer",
"composerPlaceholderFront": "Add question",
"composerPlaceholderExpression": "Add expression",
"composerPlaceholderPrompt": "Add prompt",
"composerPlaceholderCharacter": "Add a kanji or kana",
"composerAddAnswer": "Add answer",
"composerCorrectAnswer": "Correct answer",
"composerWrongAnswer": "Wrong answer",
@ -685,6 +688,13 @@
"vocabularyRemoveGloss": "Remove translation",
"vocabularyAddSense": "Add sense",
"vocabularyRemoveSense": "Remove sense",
"characterPrimaryMeaningHint": "Primary meaning, or a kana's romaji",
"characterOnReadings": "On readings",
"characterOnReadingsHint": "From Chinese, in katakana: ショク、ジキ",
"characterKunReadings": "Kun readings",
"characterKunReadingsHint": "Japanese, in hiragana, okurigana after a dot: た.べる",
"characterOn": "On",
"characterKun": "Kun",
"matchingCategories": "Categories",
"matchingCategoriesHint": "The drop-zone cards learners drag items onto. Each category needs at least one item, so a card can't have more categories than items.",
"matchingCategory": "Category {number}",
@ -1427,6 +1437,7 @@
"cardBrowserTypeVocabulary": "Words only",
"cardBrowserTypeMatching": "Matching only",
"cardBrowserTypeMap": "Maps only",
"cardBrowserTypeCharacter": "Characters only",
"cardBrowserFilterTag": "Tag",
"cardBrowserAllTags": "All tags",
"cardBrowserFilterStatus": "Status",

View file

@ -3418,6 +3418,12 @@ abstract class AppLocalizations {
/// **'Map'**
String get composerKindMap;
/// No description provided for @composerKindCharacter.
///
/// In en, this message translates to:
/// **'Character'**
String get composerKindCharacter;
/// No description provided for @composerLabelFront.
///
/// In en, this message translates to:
@ -3442,6 +3448,12 @@ abstract class AppLocalizations {
/// **'Prompt'**
String get composerLabelPrompt;
/// No description provided for @composerLabelCharacter.
///
/// In en, this message translates to:
/// **'Character'**
String get composerLabelCharacter;
/// No description provided for @composerLabelBack.
///
/// In en, this message translates to:
@ -3478,6 +3490,12 @@ abstract class AppLocalizations {
/// **'Add prompt'**
String get composerPlaceholderPrompt;
/// No description provided for @composerPlaceholderCharacter.
///
/// In en, this message translates to:
/// **'Add a kanji or kana'**
String get composerPlaceholderCharacter;
/// No description provided for @composerAddAnswer.
///
/// In en, this message translates to:
@ -3754,6 +3772,48 @@ abstract class AppLocalizations {
/// **'Remove sense'**
String get vocabularyRemoveSense;
/// No description provided for @characterPrimaryMeaningHint.
///
/// In en, this message translates to:
/// **'Primary meaning, or a kana\'s romaji'**
String get characterPrimaryMeaningHint;
/// No description provided for @characterOnReadings.
///
/// In en, this message translates to:
/// **'On readings'**
String get characterOnReadings;
/// No description provided for @characterOnReadingsHint.
///
/// In en, this message translates to:
/// **'From Chinese, in katakana: ショク、ジキ'**
String get characterOnReadingsHint;
/// No description provided for @characterKunReadings.
///
/// In en, this message translates to:
/// **'Kun readings'**
String get characterKunReadings;
/// No description provided for @characterKunReadingsHint.
///
/// In en, this message translates to:
/// **'Japanese, in hiragana, okurigana after a dot: た.べる'**
String get characterKunReadingsHint;
/// No description provided for @characterOn.
///
/// In en, this message translates to:
/// **'On'**
String get characterOn;
/// No description provided for @characterKun.
///
/// In en, this message translates to:
/// **'Kun'**
String get characterKun;
/// No description provided for @matchingCategories.
///
/// In en, this message translates to:
@ -7257,6 +7317,12 @@ abstract class AppLocalizations {
/// **'Maps only'**
String get cardBrowserTypeMap;
/// No description provided for @cardBrowserTypeCharacter.
///
/// In en, this message translates to:
/// **'Characters only'**
String get cardBrowserTypeCharacter;
/// No description provided for @cardBrowserFilterTag.
///
/// In en, this message translates to:

View file

@ -2029,6 +2029,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get composerKindMap => 'Landkarte';
@override
String get composerKindCharacter => 'Schriftzeichen';
@override
String get composerLabelFront => 'Vorderseite';
@ -2041,6 +2044,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get composerLabelPrompt => 'Aufgabe';
@override
String get composerLabelCharacter => 'Schriftzeichen';
@override
String get composerLabelBack => 'Rückseite';
@ -2059,6 +2065,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get composerPlaceholderPrompt => 'Aufgabe hinzufügen';
@override
String get composerPlaceholderCharacter => 'Kanji oder Kana hinzufügen';
@override
String get composerAddAnswer => 'Antwort hinzufügen';
@ -2221,6 +2230,30 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get vocabularyRemoveSense => 'Wortbedeutung entfernen';
@override
String get characterPrimaryMeaningHint =>
'Hauptbedeutung, bei Kana die Umschrift';
@override
String get characterOnReadings => 'On-Lesungen';
@override
String get characterOnReadingsHint =>
'Aus dem Chinesischen, in Katakana: ショク、ジキ';
@override
String get characterKunReadings => 'Kun-Lesungen';
@override
String get characterKunReadingsHint =>
'Japanisch, in Hiragana, Okurigana nach einem Punkt: た.べる';
@override
String get characterOn => 'On';
@override
String get characterKun => 'Kun';
@override
String get matchingCategories => 'Kategorien';
@ -4430,6 +4463,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cardBrowserTypeMap => 'Nur Karten';
@override
String get cardBrowserTypeCharacter => 'Nur Schriftzeichen';
@override
String get cardBrowserFilterTag => 'Tag';

View file

@ -2016,6 +2016,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get composerKindMap => 'Map';
@override
String get composerKindCharacter => 'Character';
@override
String get composerLabelFront => 'Front';
@ -2028,6 +2031,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get composerLabelPrompt => 'Prompt';
@override
String get composerLabelCharacter => 'Character';
@override
String get composerLabelBack => 'Back';
@ -2046,6 +2052,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get composerPlaceholderPrompt => 'Add prompt';
@override
String get composerPlaceholderCharacter => 'Add a kanji or kana';
@override
String get composerAddAnswer => 'Add answer';
@ -2208,6 +2217,29 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get vocabularyRemoveSense => 'Remove sense';
@override
String get characterPrimaryMeaningHint =>
'Primary meaning, or a kana\'s romaji';
@override
String get characterOnReadings => 'On readings';
@override
String get characterOnReadingsHint => 'From Chinese, in katakana: ショク、ジキ';
@override
String get characterKunReadings => 'Kun readings';
@override
String get characterKunReadingsHint =>
'Japanese, in hiragana, okurigana after a dot: た.べる';
@override
String get characterOn => 'On';
@override
String get characterKun => 'Kun';
@override
String get matchingCategories => 'Categories';
@ -4397,6 +4429,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cardBrowserTypeMap => 'Maps only';
@override
String get cardBrowserTypeCharacter => 'Characters only';
@override
String get cardBrowserFilterTag => 'Tag';

View file

@ -21,9 +21,11 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
// `senses` ([{glosses, label, pos, misc, field, dialect}], the codes as
// dictionaries write them). Schema 6 added the dictionary entry a word was
// made from (`dictionary`: {source, id}), left out for a word of its own.
// Schema 7 added the character card (type `character`) and a kanji's
// readings, `on` and `kun`, each left out while empty.
/// The schema this app writes.
const cardSchemaVersion = 6;
const cardSchemaVersion = 7;
/// Encodes a card's content into a revision's payload; [origin] names where
/// an imported card came from, in its first revision.
@ -43,6 +45,8 @@ ByteData encodeCardPayload(CardContent content, {String? origin}) =>
},
],
if (content.reading.isNotEmpty) 'reading': content.reading,
if (content.onReadings.isNotEmpty) 'on': content.onReadings,
if (content.kunReadings.isNotEmpty) 'kun': content.kunReadings,
if (content.meanings.isNotEmpty) 'meanings': content.meanings,
if (content.matching.targets.isNotEmpty ||
content.matching.items.isNotEmpty)
@ -112,6 +116,8 @@ CardContent decodeCardPayload(ByteData payload) {
answers: _answers(json['answers']),
reading: _optional<String>(json, 'reading') ?? '',
meanings: _strings(json['meanings'], 'meanings'),
onReadings: _strings(json['on'], 'on readings'),
kunReadings: _strings(json['kun'], 'kun readings'),
matching: _matching(json['matching']),
map: _optional<String>(json, 'map'),
source: pdfPageRefFromJson(json['source']),

View file

@ -55,7 +55,10 @@ enum CardBrowserKind() {
matching,
/// A region to point at on a map.
map;
map,
/// A kanji or a kana.
character;
/// The kind of [content].
static CardBrowserKind of(CardContent content) => switch (content.type) {
@ -64,6 +67,7 @@ enum CardBrowserKind() {
CardType.multipleChoice => multipleChoice,
CardType.vocabulary => vocabulary,
CardType.matching => matching,
CardType.character => character,
};
}
@ -95,9 +99,10 @@ typedef BrowsedCard = ({
});
/// What a search looks through in [card]: its front and back, a word's
/// reading, meanings and senses, a choice's answers, its hints and
/// explanation, and the names of its [tags]; folded as [searchFolded]
/// does.
/// reading, meanings and senses, a kanji's readings, a choice's answers,
/// its hints and explanation, and the names of its [tags]; folded as
/// [searchFolded] does (a kun reading's okurigana dot left out, so
/// `たべる` finds た.べる).
String searchTextOf(Card card, List<Tag> tags) {
final content = card.content;
return searchFolded(
@ -106,6 +111,8 @@ String searchTextOf(Card card, List<Tag> tags) {
content.back.plainText,
content.reading,
...content.meanings,
...content.onReadings,
for (final reading in content.kunReadings) reading.replaceAll('.', ''),
for (final sense in content.senses) ...sense.glosses,
for (final answer in content.answers) answer.content.plainText,
for (final hint in content.hints) hint.plainText,

View file

@ -18,6 +18,10 @@ enum CardType() {
/// Items to sort into targets, each into exactly one.
matching,
/// A character: a kanji with its meanings and its on and kun readings,
/// or a kana with its romaji as its meaning.
character,
}
/// The parts of a card, which merge separately when devices changed a card
@ -65,6 +69,12 @@ enum CardField() {
/// The dictionary entry a word was made from.
entry,
/// A kanji's on readings, from Chinese.
onReadings,
/// A kanji's kun readings, Japanese.
kunReadings,
}
/// What a report says is wrong with a card.
@ -230,6 +240,14 @@ final class const CardContent({
/// The dictionary entry it was made from; kept whatever its type, as the
/// page of a PDF is.
final DictionaryRef? entry,
/// A kanji's on readings, in katakana as dictionaries write them
/// (`ショク`).
final List<String> onReadings = const [],
/// A kanji's kun readings, in hiragana, the okurigana after a dot as
/// dictionaries write them (`た.べる`).
final List<String> kunReadings = const [],
}) with Equatable implements MergeableContent<CardField, CardContent> {
/// A front/back card with nothing on either side.
factory empty() =>
@ -254,6 +272,8 @@ final class const CardContent({
CardField.report => report,
CardField.senses => senses,
CardField.entry => entry,
CardField.onReadings => onReadings,
CardField.kunReadings => kunReadings,
};
@override
@ -276,6 +296,12 @@ final class const CardContent({
? value! as List<VocabularySense>
: senses,
entry: field == CardField.entry ? value as DictionaryRef? : entry,
onReadings: field == CardField.onReadings
? value! as List<String>
: onReadings,
kunReadings: field == CardField.kunReadings
? value! as List<String>
: kunReadings,
);
/// This content with some fields replaced; [withField] clears the map, the
@ -293,6 +319,8 @@ final class const CardContent({
List<RichContent>? hints,
List<VocabularySense>? senses,
DictionaryRef? entry,
List<String>? onReadings,
List<String>? kunReadings,
}) => CardContent(
type: type ?? this.type,
front: front ?? this.front,
@ -308,6 +336,8 @@ final class const CardContent({
report: report,
senses: senses ?? this.senses,
entry: entry ?? this.entry,
onReadings: onReadings ?? this.onReadings,
kunReadings: kunReadings ?? this.kunReadings,
);
/// The fields [type] uses; the others stay empty. The page it was made
@ -328,6 +358,12 @@ final class const CardContent({
CardField.senses,
},
CardType.matching => const {CardField.front, CardField.matching},
CardType.character => const {
CardField.front,
CardField.meanings,
CardField.onReadings,
CardField.kunReadings,
},
};
/// This content with only the fields of its [type]; the others empty, as
@ -393,6 +429,13 @@ final class const CardContent({
_blank(front) &&
matching.targets.every((target) => target.trim().isEmpty) &&
matching.items.every((item) => item.text.trim().isEmpty),
CardType.character =>
_blank(front) &&
[
...meanings,
...onReadings,
...kunReadings,
].every((each) => each.trim().isEmpty),
};
/// Whether the card can be studied: everything its type asks for is there.
@ -428,10 +471,20 @@ final class const CardContent({
for (var t = 0; t < matching.targets.length; t++)
matching.items.any((item) => item.target == t),
].every((filled) => filled),
// A character and something to recall of it: a kana's romaji is its
// meaning, a kanji may be known by its readings alone.
CardType.character =>
!_blank(front) &&
[
...meanings,
...onReadings,
...kunReadings,
].any((each) => each.trim().isNotEmpty),
};
/// What a list shows beside the front: the back, the right answers, the
/// reading and meanings, or the targets; one line.
/// reading and meanings, the targets, or a character's meanings and
/// readings; one line.
String get answerPreview => switch (type) {
CardType.frontBack => _firstLine(back.plainText),
CardType.multipleChoice => [
@ -443,6 +496,11 @@ final class const CardContent({
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
].where((part) => part.isNotEmpty).join(' · '),
CardType.matching => matching.targets.join(' · '),
CardType.character => [
_joined(meanings, '; '),
_joined(onReadings, '、'),
_joined(kunReadings, '、'),
].where((part) => part.isNotEmpty).join(' · '),
};
@override
@ -461,9 +519,24 @@ final class const CardContent({
report,
senses,
entry,
onReadings,
kunReadings,
];
}
bool _blank(RichContent content) => content.isBlank;
String _firstLine(String text) => text.split('\n').first;
/// The [values] that aren't blank, trimmed, joined by [separator].
String _joined(List<String> values, String separator) => [
for (final value in values)
if (value.trim().isNotEmpty) value.trim(),
].join(separator);
/// Readings as a person types them on one line: split at commas, Japanese
/// commas, semicolons or spaces, blank ones left out (`ショク、ジキ`).
List<String> splitReadings(String text) => [
for (final reading in text.split(RegExp(r'[,、,;;\s]+')))
if (reading.isNotEmpty) reading,
];

View file

@ -840,7 +840,8 @@ class const _Half({required final Widget child}) extends StatelessWidget {
);
}
/// A card's question: a word with its reading over it, else its front.
/// A card's question: a word with its reading over it, a character large,
/// else its front.
class const _Front({
required final CardContent content,
required final TextStyle? style,
@ -849,6 +850,17 @@ class const _Front({
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (content.type == CardType.character) {
return Align(
alignment: centred
? Alignment.center
: AlignmentDirectional.centerStart,
child: Text(
content.front.plainText.trim(),
style: theme.textTheme.headlineMedium,
),
);
}
if (content.type == CardType.vocabulary) {
final word = content.front.plainText.trim();
final reading = content.reading.trim();
@ -874,9 +886,9 @@ class const _Front({
}
}
/// A card's answer as its type has it: a word's meanings, a choice's
/// answers lettered (the right ones marked), a sorting's targets with their
/// items, else its back.
/// A card's answer as its type has it: a word's meanings, a character's
/// meanings and readings, a choice's answers lettered (the right ones
/// marked), a sorting's targets with their items, else its back.
class const _Back({
required final CardContent content,
required final TextStyle? style,
@ -900,6 +912,19 @@ class const _Back({
style: style,
textAlign: centred ? TextAlign.center : null,
);
case CardType.character:
final l10n = AppLocalizations.of(context);
return Text(
[
content.meanings.where((each) => each.trim().isNotEmpty).join('; '),
if (content.onReadings.isNotEmpty)
'${l10n.characterOn}: ${content.onReadings.join('、')}',
if (content.kunReadings.isNotEmpty)
'${l10n.characterKun}: ${content.kunReadings.join('、')}',
].where((line) => line.isNotEmpty).join('\n'),
style: style,
textAlign: centred ? TextAlign.center : null,
);
case CardType.multipleChoice:
final success = theme.extras.success;
return Column(
@ -1191,6 +1216,7 @@ class const _StackTile({
if (answer.correct) answer.content.plainText.trim(),
].join('; '),
CardType.matching => content.matching.targets.join(', '),
CardType.character => content.answerPreview,
CardType.frontBack => content.back.plainText.trim(),
};
Text line(String text) => Text(

View file

@ -146,6 +146,7 @@ String _kindName(AppLocalizations l10n, CardBrowserKind? kind) =>
CardBrowserKind.vocabulary => l10n.cardBrowserTypeVocabulary,
CardBrowserKind.matching => l10n.cardBrowserTypeMatching,
CardBrowserKind.map => l10n.cardBrowserTypeMap,
CardBrowserKind.character => l10n.cardBrowserTypeCharacter,
};
String _statusName(AppLocalizations l10n, CardStatus status) =>

View file

@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.d
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/character_fields.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/field_label.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/matching_fields.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/vocabulary_fields.dart';
@ -39,7 +40,10 @@ enum CardKind() {
matching,
/// A region to point at on a map: a multiple-choice card drawn on it.
map;
map,
/// A kanji or a kana.
character;
/// The kind of [content].
static CardKind of(CardContent content) => switch (content.type) {
@ -47,6 +51,7 @@ enum CardKind() {
CardType.frontBack || CardType.multipleChoice => flashcard,
CardType.vocabulary => vocabulary,
CardType.matching => matching,
CardType.character => character,
};
/// [content] as this kind, every field kept for switching back but the
@ -61,6 +66,7 @@ enum CardKind() {
),
vocabulary => plain.copyWith(type: CardType.vocabulary),
matching => plain.copyWith(type: CardType.matching),
character => plain.copyWith(type: CardType.character),
// Only an answer naming a region is one on a map.
map => content.copyWith(
type: CardType.multipleChoice,
@ -258,6 +264,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
CardKind.vocabulary => l10n.composerLabelExpression,
CardKind.matching => l10n.composerLabelPrompt,
CardKind.map => l10n.composerLabelQuestion,
CardKind.character => l10n.composerLabelCharacter,
};
return Padding(
padding: const EdgeInsets.all(24),
@ -318,6 +325,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
(CardKind.vocabulary, l10n.composerKindVocabulary),
(CardKind.matching, l10n.composerKindMatching),
(CardKind.map, l10n.composerKindMap),
(CardKind.character, l10n.composerKindCharacter),
])
ChoiceChip(
label: Text(name),
@ -377,6 +385,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
CardKind.vocabulary => l10n.composerPlaceholderExpression,
CardKind.matching => l10n.composerPlaceholderPrompt,
CardKind.map => l10n.composerPlaceholderQuestion,
CardKind.character => l10n.composerPlaceholderCharacter,
},
startExpanded: widget.focusFront,
onChanged: (front) => _change(_sent.copyWith(front: front)),
@ -439,6 +448,18 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
onSensesChanged: (senses) =>
_change(_sent.copyWith(senses: senses)),
),
CardKind.character => CharacterFields(
key: _key('character'),
meanings: content.meanings,
onReadings: content.onReadings,
kunReadings: content.kunReadings,
onMeaningsChanged: (meanings) =>
_change(_sent.copyWith(meanings: meanings)),
onOnReadingsChanged: (readings) =>
_change(_sent.copyWith(onReadings: readings)),
onKunReadingsChanged: (readings) =>
_change(_sent.copyWith(kunReadings: readings)),
),
CardKind.matching => MatchingFields(
key: _key('matching'),
matching: content.matching,
@ -703,6 +724,8 @@ class const _Conflicts({required final CardDraft draft})
CardField.report => l10n.cardReport,
CardField.senses => l10n.cardSenses,
CardField.entry => l10n.cardDictionaryEntry,
CardField.onReadings => l10n.characterOnReadings,
CardField.kunReadings => l10n.characterKunReadings,
};
}

View file

@ -0,0 +1,145 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A character's meanings, the main one first (a kana's romaji), and a
/// kanji's on and kun readings, each kind on one line as dictionaries list
/// them (`ショク、ジキ`). Its own text is the truth while it shows; give it
/// a key per card, so another card starts afresh.
class const CharacterFields({
/// The meanings.
required final List<String> meanings,
/// The on readings.
required final List<String> onReadings,
/// The kun readings.
required final List<String> kunReadings,
/// Called with all meanings after every change, blank rows included.
required final ValueChanged<List<String>> onMeaningsChanged,
/// Called with the on readings after every change.
required final ValueChanged<List<String>> onOnReadingsChanged,
/// Called with the kun readings after every change.
required final ValueChanged<List<String>> onKunReadingsChanged,
super.key,
}) extends StatefulWidget {
@override
State<CharacterFields> createState() => _CharacterFieldsState();
}
class _CharacterFieldsState() extends State<CharacterFields> {
late final _meanings = [
for (final meaning in widget.meanings.isEmpty ? [''] : widget.meanings)
TextEditingController(text: meaning),
];
late final _on = TextEditingController(text: widget.onReadings.join('、'));
late final _kun = TextEditingController(text: widget.kunReadings.join('、'));
@override
void dispose() {
for (final meaning in _meanings) {
meaning.dispose();
}
_on.dispose();
_kun.dispose();
super.dispose();
}
void _emitMeanings() =>
widget.onMeaningsChanged([for (final meaning in _meanings) meaning.text]);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
Widget readings(
String label,
String hint,
TextEditingController controller,
ValueChanged<List<String>> onChanged,
) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: labelStyle),
const SizedBox(height: 8),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: hint,
isDense: true,
border: const OutlineInputBorder(),
),
onChanged: (text) => onChanged(splitReadings(text)),
),
],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.vocabularyMeanings, style: labelStyle),
const SizedBox(height: 8),
for (final (i, meaning) in _meanings.indexed)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: meaning,
decoration: InputDecoration(
hintText: i == 0
? l10n.characterPrimaryMeaningHint
: l10n.vocabularyMeaningHint,
isDense: true,
border: const OutlineInputBorder(),
),
onChanged: (_) => _emitMeanings(),
),
),
if (_meanings.length > 1)
IconButton(
tooltip: l10n.vocabularyRemoveMeaning,
icon: const Icon(LucideIcons.x, size: 18),
onPressed: () {
setState(() => _meanings.removeAt(i).dispose());
_emitMeanings();
},
),
],
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
setState(() => _meanings.add(TextEditingController())),
icon: const Icon(LucideIcons.plus, size: 18),
label: Text(l10n.vocabularyAddMeaning),
),
),
const SizedBox(height: 24),
readings(
l10n.characterOnReadings,
l10n.characterOnReadingsHint,
_on,
widget.onOnReadingsChanged,
),
const SizedBox(height: 24),
readings(
l10n.characterKunReadings,
l10n.characterKunReadingsHint,
_kun,
widget.onKunReadingsChanged,
),
],
);
}
}

View file

@ -196,9 +196,9 @@ class StudysetMarkdownExporter(
}
/// [card] as an item of the flashcards' list, written to [list]: its
/// question, and below it its answer (a word's meanings, a choice's
/// answers as a task list, a sorting's targets with their items), its
/// hints and its explanation.
/// question, and below it its answer (a word's meanings, a character's
/// meanings and readings, a choice's answers as a task list, a sorting's
/// targets with their items), its hints and its explanation.
Future<void> _card(_Export out, StringBuffer list, Card card) async {
final content = card.content;
final texts = out.texts;
@ -221,6 +221,11 @@ class StudysetMarkdownExporter(
texts.answer,
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
);
case CardType.character:
// Its meanings, then its on and kun readings, as a list shows them.
out
..item(list, 0, texts.question, question)
..item(list, 1, texts.answer, content.answerPreview);
case CardType.multipleChoice:
out.item(list, 0, texts.question, question);
for (final answer in content.answers) {

View file

@ -318,6 +318,108 @@ class const VocabularyBody({
}
}
/// A character: large and centred, and once shown, a line, its meanings
/// (a kana's romaji) and a kanji's on and kun readings. Asking its back,
/// the meanings come first and the character with its readings answers.
class const CharacterBody({
required final Card card,
/// The row above the card's content.
required final Widget header,
required final bool asksBack,
required final bool answerShown,
/// Shows or hides the answer.
required final VoidCallback onToggle,
/// Shows the answer.
required final VoidCallback onShow,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = card.content;
final character = Text(
content.front.plainText.trim(),
textAlign: TextAlign.center,
style: theme.textTheme.displayLarge,
);
final meanings = Text(
[
for (final meaning in content.meanings)
if (meaning.trim().isNotEmpty) meaning.trim(),
].join(', '),
textAlign: TextAlign.center,
style: asksBack
? theme.textTheme.headlineMedium
: theme.textTheme.headlineSmall,
);
final readings = CharacterReadings(content: content);
return StudyCardSurface(
header: header,
onTap: onToggle,
children: [
if (asksBack) meanings else character,
..._hintsPart(card),
..._answerPart(
context,
card: card,
shown: answerShown,
onShow: onShow,
answer: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (asksBack) character else meanings,
if (content.onReadings.isNotEmpty ||
content.kunReadings.isNotEmpty) ...[
const SizedBox(height: 16),
readings,
],
],
),
),
],
);
}
}
/// A kanji's on and kun readings, each kind on a line behind its name,
/// centred.
class const CharacterReadings({required final CardContent content, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final label = theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
Widget line(String name, List<String> readings) => Text.rich(
TextSpan(
children: [
TextSpan(text: '$name ', style: label),
TextSpan(
text: readings.join('、'),
style: theme.textTheme.titleMedium,
),
],
),
textAlign: TextAlign.center,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 4,
children: [
if (content.onReadings.isNotEmpty)
line(l10n.characterOn, content.onReadings),
if (content.kunReadings.isNotEmpty)
line(l10n.characterKun, content.kunReadings),
],
);
}
}
/// A word whose reading is typed: its expression large and centred, a
/// field for the reading in kana (romaji turn into kana as typed, the
/// device's own Japanese input works too), Check below the card or Enter;

View file

@ -470,6 +470,31 @@ class const _Showing(
],
),
),
CardType.character => SwipeToRate(
key: key,
onRated: rate,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: CharacterBody(
key: key,
card: card,
header: cardHeader,
asksBack: state.asksBack,
answerShown: state.answerShown,
onToggle: toggle,
onShow: show,
),
),
const SizedBox(height: 16),
if (state.ratingFailed) ...[
const RatingFailedBanner(),
const SizedBox(height: 12),
],
],
),
),
CardType.frontBack => SwipeToRate(
key: key,
onRated: rate,
@ -501,7 +526,7 @@ class const _Showing(
final ratedBelow = switch (card.content.type) {
CardType.vocabulary when state.quiz != null || state.typesReading =>
false,
CardType.frontBack || CardType.vocabulary => true,
CardType.frontBack || CardType.vocabulary || CardType.character => true,
CardType.multipleChoice || CardType.matching => false,
};
return Scaffold(

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 {

View file

@ -493,6 +493,31 @@ void main() {
expect(find.text('to eat'), findsOneWidget);
});
testWidgets('a character shows large, and with the answer its meanings '
'and its on and kun readings', (tester) async {
await cards.create(
'japanese',
CardContent(
type: CardType.character,
front: paragraph('食'),
back: RichContent.empty(),
meanings: const ['eat', 'food'],
onReadings: const ['ショク', 'ジキ'],
kunReadings: const ['た.べる'],
),
);
await study(tester);
expect(find.text('食'), findsOneWidget);
expect(find.text('eat, food'), findsNothing);
await tester.tap(find.text('Show answer'));
await tester.pumpAndSettle();
expect(find.text('eat, food'), findsOneWidget);
expect(find.textContaining('ショク、ジキ', findRichText: true), findsOneWidget);
expect(find.textContaining('た.べる', findRichText: true), findsOneWidget);
});
testWidgets('a word with senses shows them numbered with their grammar; '
'one usually written in kana shows its reading from the start', (
tester,