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:
parent
a6699b30cb
commit
2fc4205010
20 changed files with 765 additions and 18 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 '
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue