feat(app): show and write a word's senses with their grammar
A word with senses (as a dictionary gives them) shows them when its answer shows: numbered when there are several, each with its label, its translations and its grammar codes, small and slanted, their meaning on hover. A word usually written in kana shows its reading from the start: its kanji are rarely seen, so there is nothing to hide. The composer writes the senses' translations, adds and removes senses, and keeps the codes as they came. The codes' short forms and meanings are the dictionary's own English (JMdict's entities), kept as data beside the cards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
c19a8e1db2
commit
4c47ec06c1
11 changed files with 611 additions and 34 deletions
|
|
@ -449,6 +449,11 @@
|
|||
"vocabularyMeaningHint": "Bedeutung",
|
||||
"vocabularyAddMeaning": "Bedeutung hinzufügen",
|
||||
"vocabularyRemoveMeaning": "Bedeutung entfernen",
|
||||
"vocabularyGlossHint": "Übersetzung",
|
||||
"vocabularyAddGloss": "Übersetzung hinzufügen",
|
||||
"vocabularyRemoveGloss": "Übersetzung entfernen",
|
||||
"vocabularyAddSense": "Wortbedeutung hinzufügen",
|
||||
"vocabularyRemoveSense": "Wortbedeutung entfernen",
|
||||
"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}",
|
||||
|
|
|
|||
|
|
@ -496,6 +496,11 @@
|
|||
"vocabularyMeaningHint": "Meaning",
|
||||
"vocabularyAddMeaning": "Add meaning",
|
||||
"vocabularyRemoveMeaning": "Remove meaning",
|
||||
"vocabularyGlossHint": "Translation",
|
||||
"vocabularyAddGloss": "Add translation",
|
||||
"vocabularyRemoveGloss": "Remove translation",
|
||||
"vocabularyAddSense": "Add sense",
|
||||
"vocabularyRemoveSense": "Remove sense",
|
||||
"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}",
|
||||
|
|
|
|||
|
|
@ -2722,6 +2722,36 @@ abstract class AppLocalizations {
|
|||
/// **'Remove meaning'**
|
||||
String get vocabularyRemoveMeaning;
|
||||
|
||||
/// No description provided for @vocabularyGlossHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Translation'**
|
||||
String get vocabularyGlossHint;
|
||||
|
||||
/// No description provided for @vocabularyAddGloss.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add translation'**
|
||||
String get vocabularyAddGloss;
|
||||
|
||||
/// No description provided for @vocabularyRemoveGloss.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove translation'**
|
||||
String get vocabularyRemoveGloss;
|
||||
|
||||
/// No description provided for @vocabularyAddSense.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Add sense'**
|
||||
String get vocabularyAddSense;
|
||||
|
||||
/// No description provided for @vocabularyRemoveSense.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Remove sense'**
|
||||
String get vocabularyRemoveSense;
|
||||
|
||||
/// No description provided for @matchingCategories.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1578,6 +1578,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get vocabularyRemoveMeaning => 'Bedeutung entfernen';
|
||||
|
||||
@override
|
||||
String get vocabularyGlossHint => 'Übersetzung';
|
||||
|
||||
@override
|
||||
String get vocabularyAddGloss => 'Übersetzung hinzufügen';
|
||||
|
||||
@override
|
||||
String get vocabularyRemoveGloss => 'Übersetzung entfernen';
|
||||
|
||||
@override
|
||||
String get vocabularyAddSense => 'Wortbedeutung hinzufügen';
|
||||
|
||||
@override
|
||||
String get vocabularyRemoveSense => 'Wortbedeutung entfernen';
|
||||
|
||||
@override
|
||||
String get matchingCategories => 'Kategorien';
|
||||
|
||||
|
|
|
|||
|
|
@ -1567,6 +1567,21 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get vocabularyRemoveMeaning => 'Remove meaning';
|
||||
|
||||
@override
|
||||
String get vocabularyGlossHint => 'Translation';
|
||||
|
||||
@override
|
||||
String get vocabularyAddGloss => 'Add translation';
|
||||
|
||||
@override
|
||||
String get vocabularyRemoveGloss => 'Remove translation';
|
||||
|
||||
@override
|
||||
String get vocabularyAddSense => 'Add sense';
|
||||
|
||||
@override
|
||||
String get vocabularyRemoveSense => 'Remove sense';
|
||||
|
||||
@override
|
||||
String get matchingCategories => 'Categories';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
// The grammar codes a dictionary gives a word's senses (JMdict's entities:
|
||||
// parts of speech, remarks, fields, dialects), each with a short form shown
|
||||
// beside the glosses and a description for its tooltip. They are the
|
||||
// dictionary's own English, as the glosses are, so they aren't translated.
|
||||
// A code not listed here shows as itself.
|
||||
//
|
||||
// Japanese grammar terms in the descriptions use Hepburn with macrons for
|
||||
// long vowels (keiyōshi, kenjōgo); terms without one carry none (fukushi,
|
||||
// godan). Keep to it when adding codes.
|
||||
|
||||
/// A grammar code as it shows: a short form, and what it means.
|
||||
final class const JmdictTag(
|
||||
/// The short form, shown beside the glosses.
|
||||
final String abbr,
|
||||
|
||||
/// What it means, for the tooltip.
|
||||
final String full,
|
||||
) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [abbr, full];
|
||||
}
|
||||
|
||||
/// [code] as it shows; one not listed shows as itself.
|
||||
JmdictTag jmdictTag(String code) => _tags[code] ?? JmdictTag(code, code);
|
||||
|
||||
const Map<String, JmdictTag> _tags = {
|
||||
// ── Parts of speech: nouns / pronouns ──────────────────────────────
|
||||
'n': JmdictTag('n', 'noun'),
|
||||
'n-adv': JmdictTag('n-adv', 'adverbial noun'),
|
||||
'n-pr': JmdictTag('n-pr', 'proper noun'),
|
||||
'n-pref': JmdictTag('n-pref', 'noun, used as a prefix'),
|
||||
'n-suf': JmdictTag('n-suf', 'noun, used as a suffix'),
|
||||
'n-t': JmdictTag('n-t', 'noun (temporal)'),
|
||||
'pn': JmdictTag('pron', 'pronoun'),
|
||||
'ctr': JmdictTag('counter', 'counter'),
|
||||
'num': JmdictTag('num', 'numeric'),
|
||||
|
||||
// ── Parts of speech: adjectives / adverbs ──────────────────────────
|
||||
'adj-i': JmdictTag('i-adj', 'i-adjective (keiyōshi)'),
|
||||
'adj-ix': JmdictTag('i-adj (よい/いい)', 'i-adjective (yoi/ii class)'),
|
||||
'adj-na': JmdictTag('na-adj', 'na-adjective (keiyōdōshi)'),
|
||||
'adj-no': JmdictTag(
|
||||
'no-adj',
|
||||
"noun which takes the genitive case particle 'no'",
|
||||
),
|
||||
'adj-pn': JmdictTag('pre-noun adj', 'pre-noun adjectival (rentaishi)'),
|
||||
'adj-t': JmdictTag('taru-adj', "'taru' adjective"),
|
||||
'adj-f': JmdictTag('pre-noun', 'noun or verb acting prenominally'),
|
||||
'adv': JmdictTag('adv', 'adverb (fukushi)'),
|
||||
'adv-to': JmdictTag('adv-と', "adverb taking the 'to' particle"),
|
||||
|
||||
// ── Parts of speech: verbs ─────────────────────────────────────────
|
||||
'vt': JmdictTag('vt', 'transitive verb'),
|
||||
'vi': JmdictTag('vi', 'intransitive verb'),
|
||||
'v1': JmdictTag('Ichidan', 'Ichidan verb'),
|
||||
'v1-s': JmdictTag('Ichidan (くれる)', 'Ichidan verb (kureru special class)'),
|
||||
'v5u': JmdictTag('Godan -u', "Godan verb with 'u' ending"),
|
||||
'v5u-s': JmdictTag(
|
||||
'Godan -u (irr.)',
|
||||
"Godan verb with 'u' ending (special class)",
|
||||
),
|
||||
'v5k': JmdictTag('Godan -ku', "Godan verb with 'ku' ending"),
|
||||
'v5k-s': JmdictTag('Godan -ku (行く)', 'Godan verb, iku/yuku special class'),
|
||||
'v5g': JmdictTag('Godan -gu', "Godan verb with 'gu' ending"),
|
||||
'v5s': JmdictTag('Godan -su', "Godan verb with 'su' ending"),
|
||||
'v5t': JmdictTag('Godan -tsu', "Godan verb with 'tsu' ending"),
|
||||
'v5n': JmdictTag('Godan -nu', "Godan verb with 'nu' ending"),
|
||||
'v5b': JmdictTag('Godan -bu', "Godan verb with 'bu' ending"),
|
||||
'v5m': JmdictTag('Godan -mu', "Godan verb with 'mu' ending"),
|
||||
'v5r': JmdictTag('Godan -ru', "Godan verb with 'ru' ending"),
|
||||
'v5r-i': JmdictTag(
|
||||
'Godan -ru (irr.)',
|
||||
"Godan verb with 'ru' ending (irregular)",
|
||||
),
|
||||
'v5aru': JmdictTag('Godan -aru', 'Godan verb, -aru special class'),
|
||||
'vk': JmdictTag('くる verb', 'kuru verb (special class)'),
|
||||
'vs': JmdictTag('+する', 'noun or participle taking suru'),
|
||||
'vs-i': JmdictTag('する verb', 'suru verb (included)'),
|
||||
'vs-s': JmdictTag('する verb (special)', 'suru verb (special class)'),
|
||||
'vz': JmdictTag('Ichidan -zuru', 'Ichidan verb (zuru, alternative of -jiru)'),
|
||||
'vn': JmdictTag('Godan -nu (irr.)', 'irregular nu verb'),
|
||||
'vr': JmdictTag(
|
||||
'Godan -ru (irr.)',
|
||||
'irregular ru verb, plain form ends with -ri',
|
||||
),
|
||||
'aux': JmdictTag('aux', 'auxiliary'),
|
||||
'aux-v': JmdictTag('aux verb', 'auxiliary verb'),
|
||||
'aux-adj': JmdictTag('aux adj', 'auxiliary adjective'),
|
||||
'cop': JmdictTag('copula', 'copula'),
|
||||
|
||||
// ── Parts of speech: other ─────────────────────────────────────────
|
||||
'conj': JmdictTag('conj', 'conjunction'),
|
||||
'int': JmdictTag('int', 'interjection (kandōshi)'),
|
||||
'prt': JmdictTag('prt', 'particle'),
|
||||
'pref': JmdictTag('pref', 'prefix'),
|
||||
'suf': JmdictTag('suf', 'suffix'),
|
||||
'exp': JmdictTag('exp', 'expression (phrase, clause, etc.)'),
|
||||
'unc': JmdictTag('unclass', 'unclassified'),
|
||||
|
||||
// ── Misc (usage / register notes) ──────────────────────────────────
|
||||
'uk': JmdictTag('kana', 'usually written using kana alone'),
|
||||
'abbr': JmdictTag('abbr', 'abbreviation'),
|
||||
'arch': JmdictTag('arch', 'archaic'),
|
||||
'obs': JmdictTag('obs', 'obsolete term'),
|
||||
'rare': JmdictTag('rare', 'rare term'),
|
||||
'col': JmdictTag('col', 'colloquial'),
|
||||
'sl': JmdictTag('sl', 'slang'),
|
||||
'net-sl': JmdictTag('net-sl', 'internet slang'),
|
||||
'vulg': JmdictTag('vulg', 'vulgar expression or word'),
|
||||
'derog': JmdictTag('derog', 'derogatory'),
|
||||
'fam': JmdictTag('fam', 'familiar language'),
|
||||
'hon': JmdictTag('hon', 'honorific or respectful (sonkeigo) language'),
|
||||
'hum': JmdictTag('hum', 'humble (kenjōgo) language'),
|
||||
'pol': JmdictTag('pol', 'polite (teineigo) language'),
|
||||
'pol-suf': JmdictTag('pol suf', 'polite suffix'),
|
||||
'chn': JmdictTag('child', "children's language"),
|
||||
'fem': JmdictTag('fem', 'female term, language, or name'),
|
||||
'male': JmdictTag('male', 'male term, language, or name'),
|
||||
'joc': JmdictTag('joc', 'jocular, humorous term'),
|
||||
'sens': JmdictTag('sens', 'sensitive'),
|
||||
'on-mim': JmdictTag('onomat', 'onomatopoeic or mimetic word'),
|
||||
'yoji': JmdictTag('yojijukugo', 'four-character idiom'),
|
||||
'proverb': JmdictTag('proverb', 'proverb'),
|
||||
'id': JmdictTag('idiom', 'idiomatic expression'),
|
||||
'quote': JmdictTag('quote', 'quotation'),
|
||||
|
||||
// ── Field of application (domains) ─────────────────────────────────
|
||||
'med': JmdictTag('med', 'medicine'),
|
||||
'anat': JmdictTag('anat', 'anatomy'),
|
||||
'comp': JmdictTag('comp', 'computing'),
|
||||
'math': JmdictTag('math', 'mathematics'),
|
||||
'physics': JmdictTag('physics', 'physics'),
|
||||
'chem': JmdictTag('chem', 'chemistry'),
|
||||
'biol': JmdictTag('biol', 'biology'),
|
||||
'bot': JmdictTag('bot', 'botany'),
|
||||
'zool': JmdictTag('zool', 'zoology'),
|
||||
'astron': JmdictTag('astron', 'astronomy'),
|
||||
'geol': JmdictTag('geol', 'geology'),
|
||||
'ling': JmdictTag('ling', 'linguistics'),
|
||||
'law': JmdictTag('law', 'law'),
|
||||
'econ': JmdictTag('econ', 'economics'),
|
||||
'finc': JmdictTag('finance', 'finance'),
|
||||
'bus': JmdictTag('business', 'business'),
|
||||
'mil': JmdictTag('mil', 'military'),
|
||||
'music': JmdictTag('music', 'music'),
|
||||
'sports': JmdictTag('sports', 'sports'),
|
||||
'food': JmdictTag('food', 'food, cooking'),
|
||||
'archit': JmdictTag('archit', 'architecture'),
|
||||
'art': JmdictTag('art', 'art, aesthetics'),
|
||||
'Buddh': JmdictTag('Buddh', 'Buddhism'),
|
||||
'Shinto': JmdictTag('Shinto', 'Shinto'),
|
||||
'Christn': JmdictTag('Christ', 'Christianity'),
|
||||
'gramm': JmdictTag('gramm', 'grammar'),
|
||||
'physiol': JmdictTag('physiol', 'physiology'),
|
||||
'psych': JmdictTag('psych', 'psychology, psychiatry'),
|
||||
|
||||
// ── Dialect ────────────────────────────────────────────────────────
|
||||
'ksb': JmdictTag('Kansai', 'Kansai-ben'),
|
||||
'ktb': JmdictTag('Kantou', 'Kantou-ben'),
|
||||
'kyb': JmdictTag('Kyoto', 'Kyoto-ben'),
|
||||
'osb': JmdictTag('Osaka', 'Osaka-ben'),
|
||||
'tsb': JmdictTag('Tosa', 'Tosa-ben'),
|
||||
'thb': JmdictTag('Touhoku', 'Touhoku-ben'),
|
||||
'tsug': JmdictTag('Tsugaru', 'Tsugaru-ben'),
|
||||
'kyu': JmdictTag('Kyuushuu', 'Kyuushuu-ben'),
|
||||
'rkb': JmdictTag('Ryuukyuu', 'Ryuukyuu-ben'),
|
||||
'nab': JmdictTag('Nagano', 'Nagano-ben'),
|
||||
'hob': JmdictTag('Hokkaido', 'Hokkaido-ben'),
|
||||
|
||||
// ── Bilingual-dictionary parts of speech (English, Greek) ──────────
|
||||
// The non-JMdict catalogs tag a sense with the part of speech spelled
|
||||
// out (`noun`, not `n`), so without these an English or Greek card
|
||||
// annotates itself with the word "noun" and a tooltip repeating it —
|
||||
// the fallback working as designed, but conspicuously wordier than the
|
||||
// Japanese cards beside it. Abbreviating them here is what puts the two
|
||||
// on the same footing; the full form still carries the tooltip.
|
||||
//
|
||||
// Keys are the codes the seeders emit, so they sit in the same
|
||||
// namespace as the JMdict ones. No collision: JMdict spells every code
|
||||
// short (`n`, `adj-i`, `vs`), these are the long words.
|
||||
'noun': JmdictTag('n', 'noun'),
|
||||
'verb': JmdictTag('v', 'verb'),
|
||||
'adjective': JmdictTag('adj', 'adjective'),
|
||||
'adverb': JmdictTag('adv', 'adverb'),
|
||||
'pronoun': JmdictTag('pron', 'pronoun'),
|
||||
'preposition': JmdictTag('prep', 'preposition'),
|
||||
'conjunction': JmdictTag('conj', 'conjunction'),
|
||||
'interjection': JmdictTag('int', 'interjection'),
|
||||
'particle': JmdictTag('part', 'particle'),
|
||||
'phrase': JmdictTag('phrase', 'phrase, expression'),
|
||||
'compound': JmdictTag('comp', 'compound'),
|
||||
};
|
||||
|
|
@ -371,6 +371,9 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
|||
_change(_sent.copyWith(reading: reading)),
|
||||
onMeaningsChanged: (meanings) =>
|
||||
_change(_sent.copyWith(meanings: meanings)),
|
||||
senses: content.senses,
|
||||
onSensesChanged: (senses) =>
|
||||
_change(_sent.copyWith(senses: senses)),
|
||||
),
|
||||
CardKind.matching => MatchingFields(
|
||||
key: _key('matching'),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/jmdict_tags.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A word's reading and meanings, the main meaning first: plain one-line
|
||||
/// fields, as readings and meanings are short. Its own text is the truth
|
||||
/// while it shows; give it a key per card, so another card starts afresh.
|
||||
/// fields, as readings and meanings are short. A word with senses (from a
|
||||
/// dictionary) shows them instead: each numbered with its grammar codes,
|
||||
/// its translations to write. Its own text is the truth while it shows;
|
||||
/// give it a key per card, so another card starts afresh.
|
||||
class const VocabularyFields({
|
||||
/// The reading.
|
||||
required final String reading,
|
||||
|
|
@ -17,6 +21,12 @@ class const VocabularyFields({
|
|||
|
||||
/// Called with all meanings after every change, blank rows included.
|
||||
required final ValueChanged<List<String>> onMeaningsChanged,
|
||||
|
||||
/// The word's senses; empty for a word of meanings alone.
|
||||
final List<VocabularySense> senses = const [],
|
||||
|
||||
/// Called with all senses after every change, blank glosses included.
|
||||
final ValueChanged<List<VocabularySense>>? onSensesChanged,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -67,44 +77,195 @@ class _VocabularyFieldsState() extends State<VocabularyFields> {
|
|||
const SizedBox(height: 24),
|
||||
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.vocabularyPrimaryMeaningHint
|
||||
: l10n.vocabularyMeaningHint,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
if (widget.senses.isNotEmpty)
|
||||
_SenseFields(
|
||||
senses: widget.senses,
|
||||
onChanged: widget.onSensesChanged ?? (_) {},
|
||||
)
|
||||
else ...[
|
||||
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.vocabularyPrimaryMeaningHint
|
||||
: l10n.vocabularyMeaningHint,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => _emitMeanings(),
|
||||
),
|
||||
onChanged: (_) => _emitMeanings(),
|
||||
),
|
||||
),
|
||||
if (_meanings.length > 1)
|
||||
IconButton(
|
||||
tooltip: l10n.vocabularyRemoveMeaning,
|
||||
icon: const Icon(LucideIcons.x, size: 18),
|
||||
onPressed: () {
|
||||
setState(() => _meanings.removeAt(i).dispose());
|
||||
_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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A word's senses to write: each numbered, its grammar codes beside the
|
||||
/// number and a way to remove it, its translations below, and a way to add
|
||||
/// a translation or a sense. The codes come from the dictionary and stay as
|
||||
/// they are.
|
||||
class const _SenseFields({
|
||||
required final List<VocabularySense> senses,
|
||||
required final ValueChanged<List<VocabularySense>> onChanged,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_SenseFields> createState() => _SenseFieldsState();
|
||||
}
|
||||
|
||||
class _SenseFieldsState() extends State<_SenseFields> {
|
||||
late final _senses = [...widget.senses];
|
||||
late final _glosses = [
|
||||
for (final sense in widget.senses)
|
||||
[
|
||||
for (final gloss in sense.glosses.isEmpty ? [''] : sense.glosses)
|
||||
TextEditingController(text: gloss),
|
||||
],
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final glosses in _glosses) {
|
||||
for (final gloss in glosses) {
|
||||
gloss.dispose();
|
||||
}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _emit() => widget.onChanged([
|
||||
for (final (i, sense) in _senses.indexed)
|
||||
VocabularySense(
|
||||
glosses: [for (final gloss in _glosses[i]) gloss.text],
|
||||
label: sense.label,
|
||||
pos: sense.pos,
|
||||
misc: sense.misc,
|
||||
field: sense.field,
|
||||
dialect: sense.dialect,
|
||||
),
|
||||
]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, sense) in _senses.indexed) ...[
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${i + 1}.',
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: muted),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
[for (final code in sense.tags) jmdictTag(code).abbr].join(
|
||||
', ',
|
||||
),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: muted,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.vocabularyRemoveSense,
|
||||
icon: const Icon(LucideIcons.trash2, size: 18),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_senses.removeAt(i);
|
||||
for (final gloss in _glosses.removeAt(i)) {
|
||||
gloss.dispose();
|
||||
}
|
||||
});
|
||||
_emit();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final (g, gloss) in _glosses[i].indexed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24, bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: gloss,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.vocabularyGlossHint,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => _emit(),
|
||||
),
|
||||
),
|
||||
if (_glosses[i].length > 1)
|
||||
IconButton(
|
||||
tooltip: l10n.vocabularyRemoveGloss,
|
||||
icon: const Icon(LucideIcons.x, size: 18),
|
||||
onPressed: () {
|
||||
setState(() => _glosses[i].removeAt(g).dispose());
|
||||
_emit();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
setState(() => _glosses[i].add(TextEditingController())),
|
||||
icon: const Icon(LucideIcons.plus, size: 18),
|
||||
label: Text(l10n.vocabularyAddGloss),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => setState(
|
||||
() => _meanings.add(TextEditingController()),
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_senses.add(const VocabularySense(glosses: []));
|
||||
_glosses.add([TextEditingController()]);
|
||||
}),
|
||||
icon: const Icon(LucideIcons.plus, size: 18),
|
||||
label: Text(l10n.vocabularyAddMeaning),
|
||||
label: Text(l10n.vocabularyAddSense),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/jmdict_tags.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_extras.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||
|
|
@ -279,8 +280,9 @@ class const VocabularyBody({
|
|||
final theme = Theme.of(context);
|
||||
Widget expression(TextStyle? style, {required bool reading}) =>
|
||||
_Expression(card: card, style: style, showReading: reading);
|
||||
Widget meanings(TextStyle? style) =>
|
||||
_Meanings(meanings: card.content.meanings, style: style);
|
||||
Widget meanings(TextStyle? style) => card.content.senses.isEmpty
|
||||
? _Meanings(meanings: card.content.meanings, style: style)
|
||||
: _Senses(senses: card.content.senses, style: style);
|
||||
return StudyCardSurface(
|
||||
header: header,
|
||||
onTap: onToggle,
|
||||
|
|
@ -288,8 +290,12 @@ class const VocabularyBody({
|
|||
if (asksBack)
|
||||
meanings(theme.textTheme.headlineMedium)
|
||||
else
|
||||
// The reading is part of what is recalled.
|
||||
expression(theme.textTheme.headlineMedium, reading: answerShown),
|
||||
// The reading is part of what is recalled, but for a word usually
|
||||
// written in kana: its kanji are rarely seen, so nothing to hide.
|
||||
expression(
|
||||
theme.textTheme.headlineMedium,
|
||||
reading: answerShown || card.content.usuallyKana,
|
||||
),
|
||||
..._hintsPart(card),
|
||||
..._answerPart(
|
||||
context,
|
||||
|
|
@ -391,6 +397,86 @@ class const _Meanings({
|
|||
}
|
||||
}
|
||||
|
||||
/// A word's senses in [style]: each its label, its glosses and its grammar
|
||||
/// codes, small and slanted, their meaning on hover; several numbered.
|
||||
class const _Senses({
|
||||
required final List<VocabularySense> senses,
|
||||
required final TextStyle? style,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final shown = [
|
||||
for (final sense in senses)
|
||||
if (sense.glosses.any((gloss) => gloss.trim().isNotEmpty)) sense,
|
||||
];
|
||||
final tagStyle = (style ?? const TextStyle()).copyWith(
|
||||
fontSize: (style?.fontSize ?? 14) * 0.85,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
final numbered = shown.length > 1;
|
||||
Widget sense(int index, VocabularySense sense) {
|
||||
final label = sense.label?.trim() ?? '';
|
||||
final tags = sense.tags;
|
||||
final content = Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 2,
|
||||
alignment: numbered ? WrapAlignment.start : WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (label.isNotEmpty) Text('($label)', style: tagStyle),
|
||||
Text(
|
||||
[
|
||||
for (final gloss in sense.glosses)
|
||||
if (gloss.trim().isNotEmpty) gloss.trim(),
|
||||
].join('; '),
|
||||
style: style,
|
||||
),
|
||||
for (final (i, code) in tags.indexed)
|
||||
Tooltip(
|
||||
message: jmdictTag(code).full,
|
||||
child: Text(
|
||||
i == tags.length - 1
|
||||
? jmdictTag(code).abbr
|
||||
: '${jmdictTag(code).abbr},',
|
||||
style: tagStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!numbered) {
|
||||
return content;
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Text(
|
||||
'${index + 1}.',
|
||||
style: style?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Expanded(child: content),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final (i, each) in shown.indexed)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: i == shown.length - 1 ? 0 : 8),
|
||||
child: sense(i, each),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Below a card answered by picking or sorting: Check, full width, once
|
||||
/// [canCheck]; once [checked], the [ratings] in its place, beneath a word
|
||||
/// that the last rating wasn't kept when [ratingFailed].
|
||||
|
|
|
|||
|
|
@ -208,6 +208,41 @@ void main() {
|
|||
expect(saved.explanation, isNull);
|
||||
});
|
||||
|
||||
testWidgets("a word's senses are written as such, their grammar kept, its "
|
||||
'meanings taken from them', (tester) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: paragraph('食べる'),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['to eat', 'to live on'],
|
||||
senses: const [
|
||||
VocabularySense(glosses: ['to eat'], pos: ['v1']),
|
||||
VocabularySense(glosses: ['to live on']),
|
||||
],
|
||||
),
|
||||
);
|
||||
await showStudyset(tester);
|
||||
await open(tester, '食べる');
|
||||
expect(find.text('Ichidan'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Remove sense').first);
|
||||
await settle(tester);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'to live on'),
|
||||
'to make a living',
|
||||
);
|
||||
await tester.tap(find.text('New flashcard'));
|
||||
await settle(tester);
|
||||
|
||||
final saved = (await cards.cardsOf('japanese')).first.content;
|
||||
expect(saved.senses, const [
|
||||
VocabularySense(glosses: ['to make a living']),
|
||||
]);
|
||||
expect(saved.meanings, ['to make a living']);
|
||||
});
|
||||
|
||||
testWidgets('New flashcard opens the new card, also while the changed one '
|
||||
'it leaves is being saved', (tester) async {
|
||||
await cards.create('japanese', card('猫', 'cat'));
|
||||
|
|
|
|||
|
|
@ -419,6 +419,34 @@ void main() {
|
|||
expect(find.text('to eat'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a word with senses shows them numbered with their grammar; '
|
||||
'one usually written in kana shows its reading from the start', (
|
||||
tester,
|
||||
) async {
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: paragraph('蛙'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'かえる',
|
||||
senses: const [
|
||||
VocabularySense(glosses: ['frog'], pos: ['n'], misc: ['uk']),
|
||||
VocabularySense(glosses: ['rain frog'], label: 'zoology'),
|
||||
],
|
||||
),
|
||||
);
|
||||
await study(tester);
|
||||
expect(find.text('かえる'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Show answer'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('1.'), findsOneWidget);
|
||||
expect(find.text('frog'), findsOneWidget);
|
||||
expect(find.text('(zoology)'), findsOneWidget);
|
||||
expect(find.byTooltip('usually written using kana alone'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the session settings narrow the cards studied to the tags '
|
||||
'chosen', (tester) async {
|
||||
final verb = await tags.create('verb');
|
||||
|
|
|
|||
Loading…
Reference in a new issue