feat(app): kanji and kana in the dictionary, beside the words

Where the instance holds the characters of a studyset's language, the
dictionary screen has a tab for them, Kanji & kana: search by character,
reading (kana or romaji) or meaning, and whole levels to add, under their
names: the kana (hiragana, katakana), the JLPT levels and the school
grades. A character becomes a character card, its meanings (a kana's
romaji) and readings, tagged with its level (JLPT-N5), school grade
(Grade 1 … Grade 6, Secondary school) and script (Hiragana, Katakana). A
character card's Look up searches the characters, and an entry looked up
again on a card swaps the tags the one before brought, as words do.

A dictionary's entries are now words or characters (DictionaryItem), as
its kind says.

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:19:22 +02:00
commit cec518350a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
20 changed files with 1034 additions and 158 deletions

View file

@ -515,6 +515,28 @@
"dictionaryConfirmAdd": "Hinzufügen",
"dictionaryCredits": "Wörter aus {sources}.",
"dictionaryLookUp": "Im Wörterbuch nachschlagen",
"dictionaryCharacterSearchHint": "Ein Kanji oder Kana, eine Lesung in Kana oder Romaji, oder eine Bedeutung",
"dictionaryTabWords": "Wörter",
"dictionaryTabCharacters": "Kanji & Kana",
"dictionaryLabelHiragana": "Hiragana",
"dictionaryLabelKatakana": "Katakana",
"dictionaryGrade": "Klasse {grade}",
"@dictionaryGrade": {"placeholders": {"grade": {"type": "int"}}},
"dictionaryLabelSecondary": "Weiterführende Schule",
"dictionaryGroupKana": "Kana",
"dictionaryGroupJlpt": "JLPT-Stufen",
"dictionaryGroupGrades": "Schuljahre",
"dictionaryStrokes": "{count, plural, =1{1 Strich} other{{count} Striche}}",
"@dictionaryStrokes": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharacterLevelsHint": "Jedes Schriftzeichen einer Stufe wird eine Karteikarte, in der Reihenfolge ihrer Liste, mit Stufe, Schuljahr oder Schrift als Tags. Schriftzeichen, die schon in diesem Lernset sind, werden ausgelassen.",
"dictionaryAddCharacters": "{count, plural, =1{1 Schriftzeichen hinzufügen} other{{count} Schriftzeichen hinzufügen}}",
"@dictionaryAddCharacters": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharactersAdded": "{count, plural, =0{Keine neuen Schriftzeichen.} =1{1 Schriftzeichen hinzugefügt.} other{{count} Schriftzeichen hinzugefügt.}}",
"@dictionaryCharactersAdded": {"placeholders": {"count": {"type": "int"}}},
"dictionaryConfirmCharactersTitle": "{count} Schriftzeichen hinzufügen?",
"@dictionaryConfirmCharactersTitle": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharacterCredits": "Schriftzeichen aus {sources}.",
"@dictionaryCharacterCredits": {"placeholders": {"sources": {"type": "String"}}},
"aboutDictionaries": "Wörterbücher dieser Instanz",
"cardReportTitle": "Fehler melden",
"cardReportSubtitle": "Markiere diese Karte, um sie später zu finden und zu korrigieren.",

View file

@ -571,6 +571,28 @@
"dictionaryCredits": "Words from {sources}.",
"@dictionaryCredits": {"placeholders": {"sources": {"type": "String"}}},
"dictionaryLookUp": "Look up in the dictionary",
"dictionaryCharacterSearchHint": "A kanji or kana, a reading in kana or romaji, or a meaning",
"dictionaryTabWords": "Words",
"dictionaryTabCharacters": "Kanji & kana",
"dictionaryLabelHiragana": "Hiragana",
"dictionaryLabelKatakana": "Katakana",
"dictionaryGrade": "Grade {grade}",
"@dictionaryGrade": {"placeholders": {"grade": {"type": "int"}}},
"dictionaryLabelSecondary": "Secondary school",
"dictionaryGroupKana": "Kana",
"dictionaryGroupJlpt": "JLPT levels",
"dictionaryGroupGrades": "School grades",
"dictionaryStrokes": "{count, plural, =1{1 stroke} other{{count} strokes}}",
"@dictionaryStrokes": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharacterLevelsHint": "Every character of a level becomes a flashcard, in the order of the level's list, tagged with its level, school grade or script. Characters already in this studyset are left out.",
"dictionaryAddCharacters": "{count, plural, =1{Add 1 character} other{Add {count} characters}}",
"@dictionaryAddCharacters": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharactersAdded": "{count, plural, =0{No new characters.} =1{Added 1 character.} other{Added {count} characters.}}",
"@dictionaryCharactersAdded": {"placeholders": {"count": {"type": "int"}}},
"dictionaryConfirmCharactersTitle": "Add {count} characters?",
"@dictionaryConfirmCharactersTitle": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharacterCredits": "Characters from {sources}.",
"@dictionaryCharacterCredits": {"placeholders": {"sources": {"type": "String"}}},
"aboutDictionaries": "Dictionaries on this instance",
"cardReportTitle": "Report an error",
"cardReportSubtitle": "Flag this card so you can find and fix it later.",

View file

@ -3112,6 +3112,102 @@ abstract class AppLocalizations {
/// **'Look up in the dictionary'**
String get dictionaryLookUp;
/// No description provided for @dictionaryCharacterSearchHint.
///
/// In en, this message translates to:
/// **'A kanji or kana, a reading in kana or romaji, or a meaning'**
String get dictionaryCharacterSearchHint;
/// No description provided for @dictionaryTabWords.
///
/// In en, this message translates to:
/// **'Words'**
String get dictionaryTabWords;
/// No description provided for @dictionaryTabCharacters.
///
/// In en, this message translates to:
/// **'Kanji & kana'**
String get dictionaryTabCharacters;
/// No description provided for @dictionaryLabelHiragana.
///
/// In en, this message translates to:
/// **'Hiragana'**
String get dictionaryLabelHiragana;
/// No description provided for @dictionaryLabelKatakana.
///
/// In en, this message translates to:
/// **'Katakana'**
String get dictionaryLabelKatakana;
/// No description provided for @dictionaryGrade.
///
/// In en, this message translates to:
/// **'Grade {grade}'**
String dictionaryGrade(int grade);
/// No description provided for @dictionaryLabelSecondary.
///
/// In en, this message translates to:
/// **'Secondary school'**
String get dictionaryLabelSecondary;
/// No description provided for @dictionaryGroupKana.
///
/// In en, this message translates to:
/// **'Kana'**
String get dictionaryGroupKana;
/// No description provided for @dictionaryGroupJlpt.
///
/// In en, this message translates to:
/// **'JLPT levels'**
String get dictionaryGroupJlpt;
/// No description provided for @dictionaryGroupGrades.
///
/// In en, this message translates to:
/// **'School grades'**
String get dictionaryGroupGrades;
/// No description provided for @dictionaryStrokes.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 stroke} other{{count} strokes}}'**
String dictionaryStrokes(int count);
/// No description provided for @dictionaryCharacterLevelsHint.
///
/// In en, this message translates to:
/// **'Every character of a level becomes a flashcard, in the order of the level\'s list, tagged with its level, school grade or script. Characters already in this studyset are left out.'**
String get dictionaryCharacterLevelsHint;
/// No description provided for @dictionaryAddCharacters.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Add 1 character} other{Add {count} characters}}'**
String dictionaryAddCharacters(int count);
/// No description provided for @dictionaryCharactersAdded.
///
/// In en, this message translates to:
/// **'{count, plural, =0{No new characters.} =1{Added 1 character.} other{Added {count} characters.}}'**
String dictionaryCharactersAdded(int count);
/// No description provided for @dictionaryConfirmCharactersTitle.
///
/// In en, this message translates to:
/// **'Add {count} characters?'**
String dictionaryConfirmCharactersTitle(int count);
/// No description provided for @dictionaryCharacterCredits.
///
/// In en, this message translates to:
/// **'Characters from {sources}.'**
String dictionaryCharacterCredits(String sources);
/// No description provided for @aboutDictionaries.
///
/// In en, this message translates to:

View file

@ -1868,6 +1868,87 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get dictionaryLookUp => 'Im Wörterbuch nachschlagen';
@override
String get dictionaryCharacterSearchHint =>
'Ein Kanji oder Kana, eine Lesung in Kana oder Romaji, oder eine Bedeutung';
@override
String get dictionaryTabWords => 'Wörter';
@override
String get dictionaryTabCharacters => 'Kanji & Kana';
@override
String get dictionaryLabelHiragana => 'Hiragana';
@override
String get dictionaryLabelKatakana => 'Katakana';
@override
String dictionaryGrade(int grade) {
return 'Klasse $grade';
}
@override
String get dictionaryLabelSecondary => 'Weiterführende Schule';
@override
String get dictionaryGroupKana => 'Kana';
@override
String get dictionaryGroupJlpt => 'JLPT-Stufen';
@override
String get dictionaryGroupGrades => 'Schuljahre';
@override
String dictionaryStrokes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Striche',
one: '1 Strich',
);
return '$_temp0';
}
@override
String get dictionaryCharacterLevelsHint =>
'Jedes Schriftzeichen einer Stufe wird eine Karteikarte, in der Reihenfolge ihrer Liste, mit Stufe, Schuljahr oder Schrift als Tags. Schriftzeichen, die schon in diesem Lernset sind, werden ausgelassen.';
@override
String dictionaryAddCharacters(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Schriftzeichen hinzufügen',
one: '1 Schriftzeichen hinzufügen',
);
return '$_temp0';
}
@override
String dictionaryCharactersAdded(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Schriftzeichen hinzugefügt.',
one: '1 Schriftzeichen hinzugefügt.',
zero: 'Keine neuen Schriftzeichen.',
);
return '$_temp0';
}
@override
String dictionaryConfirmCharactersTitle(int count) {
return '$count Schriftzeichen hinzufügen?';
}
@override
String dictionaryCharacterCredits(String sources) {
return 'Schriftzeichen aus $sources.';
}
@override
String get aboutDictionaries => 'Wörterbücher dieser Instanz';

View file

@ -1857,6 +1857,87 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get dictionaryLookUp => 'Look up in the dictionary';
@override
String get dictionaryCharacterSearchHint =>
'A kanji or kana, a reading in kana or romaji, or a meaning';
@override
String get dictionaryTabWords => 'Words';
@override
String get dictionaryTabCharacters => 'Kanji & kana';
@override
String get dictionaryLabelHiragana => 'Hiragana';
@override
String get dictionaryLabelKatakana => 'Katakana';
@override
String dictionaryGrade(int grade) {
return 'Grade $grade';
}
@override
String get dictionaryLabelSecondary => 'Secondary school';
@override
String get dictionaryGroupKana => 'Kana';
@override
String get dictionaryGroupJlpt => 'JLPT levels';
@override
String get dictionaryGroupGrades => 'School grades';
@override
String dictionaryStrokes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count strokes',
one: '1 stroke',
);
return '$_temp0';
}
@override
String get dictionaryCharacterLevelsHint =>
'Every character of a level becomes a flashcard, in the order of the level\'s list, tagged with its level, school grade or script. Characters already in this studyset are left out.';
@override
String dictionaryAddCharacters(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Add $count characters',
one: 'Add 1 character',
);
return '$_temp0';
}
@override
String dictionaryCharactersAdded(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Added $count characters.',
one: 'Added 1 character.',
zero: 'No new characters.',
);
return '$_temp0';
}
@override
String dictionaryConfirmCharactersTitle(int count) {
return 'Add $count characters?';
}
@override
String dictionaryCharacterCredits(String sources) {
return 'Characters from $sources.';
}
@override
String get aboutDictionaries => 'Dictionaries on this instance';

View file

@ -280,7 +280,7 @@ class CardComposerBloc(
content: event.content,
tagIds: {
for (final id in draft.tagIds)
if (!isWordTagName(tagging.byId(id)?.name ?? '')) id,
if (!isDictionaryTagName(tagging.byId(id)?.name ?? '')) id,
},
newTagNames: event.tagNames,
),

View file

@ -147,39 +147,47 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
);
}
/// Looks the word on the front up in [dictionary], and writes the card
/// with the word chosen: its headword, reading and senses, and where it
/// is from, and its tags (level and word class) as the dictionary screen
/// gives them. Hints, an explanation and the person's tags stay.
/// Looks the word or character on the front up in [dictionary], and
/// writes the card with the entry chosen: a word's headword, reading and
/// senses, a character's meanings and readings, where it is from, and
/// its tags as the dictionary screen gives them. Hints, an explanation
/// and the person's tags stay.
Future<void> _lookUp(Dictionary dictionary) async {
// The words other cards of the studyset were made from show as such.
final made = {
for (final draft in context.read<CardComposerBloc>().state.items)
if (draft.id != widget.draft.id) ?draft.content.entry,
};
final word = await lookUpWord(
final item = await lookUp(
context,
dictionary,
query: _sent.front.plainText.trim(),
inStudyset: made,
);
if (word == null || !mounted) {
if (item == null || !mounted) {
return;
}
final filled = _sent
.copyWith(
front: RichContent.plain(word.headword),
reading: word.reading ?? '',
senses: word.senses,
// Its sentences as hints, unless the card has hints of its own.
hints: _sent.hints.isEmpty ? word.exampleHints : null,
entry: word.ref,
)
.normalized;
final filled = switch (item) {
DictionaryWord() => _sent.copyWith(
front: RichContent.plain(item.headword),
reading: item.reading ?? '',
senses: item.senses,
// Its sentences as hints, unless the card has hints of its own.
hints: _sent.hints.isEmpty ? item.exampleHints : null,
entry: item.ref,
),
DictionaryCharacter() => _sent.copyWith(
front: RichContent.plain(item.headword),
meanings: item.meaningsShown,
onReadings: item.onReadings,
kunReadings: item.kunReadings,
entry: item.ref,
),
}.normalized;
// The fields start afresh with it, as with content from elsewhere.
setState(() => _revision++);
_sent = filled;
_send(CardComposerEventLookedUp(widget.draft.id, filled, word.tagNames));
_send(CardComposerEventLookedUp(widget.draft.id, filled, item.tagNames));
}
void _send(CardComposerEvent event) =>
@ -356,10 +364,15 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
onPinPressed: () => _send(
const CardComposerEventPinToggled(CardPin.front),
),
// A word, from the dictionary of the studyset's
// language where the instance has one.
action: kind == CardKind.vocabulary
// A word or a character, from the dictionary of the
// studyset's language where the instance has one.
action:
kind == CardKind.vocabulary ||
kind == CardKind.character
? StudysetDictionary(
kind: kind == CardKind.character
? DictionaryKind.characters
: DictionaryKind.words,
builder: (context, dictionary) =>
switch (dictionary) {
null => const SizedBox.shrink(),

View file

@ -8,8 +8,26 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_rep
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
/// The labels a whole level is added by, in the order offered: for
/// Japanese the JLPT levels, easiest first, then the other common words.
/// Japanese words the JLPT levels, easiest first, then the other common
/// words; for its characters the kana, then the JLPT levels, then the
/// school grades.
List<String> levelLabelsOf(Dictionary dictionary) => switch (dictionary) {
Dictionary(kind: DictionaryKind.characters) => const [
'hiragana',
'katakana',
'jlpt:n5',
'jlpt:n4',
'jlpt:n3',
'jlpt:n2',
'jlpt:n1',
'grade:1',
'grade:2',
'grade:3',
'grade:4',
'grade:5',
'grade:6',
'grade:secondary',
],
Dictionary(language: 'ja') => const [
'jlpt:n5',
'jlpt:n4',
@ -173,7 +191,7 @@ typedef DictionaryOutcome = ({DictionaryAdded added, String? word});
/// chosen, how far adding one is, and what the last adding came to.
final class const DictionaryState({
final String query = '',
final List<DictionaryWord> results = const [],
final List<DictionaryItem> results = const [],
final bool searching = false,
final bool unreachable = false,
final Set<DictionaryRef> inStudyset = const {},
@ -191,7 +209,7 @@ final class const DictionaryState({
/// replaced each time.
DictionaryState copyWith({
String? query,
List<DictionaryWord>? results,
List<DictionaryItem>? results,
bool? searching,
bool? unreachable,
Set<DictionaryRef>? inStudyset,
@ -249,8 +267,8 @@ final class const DictionaryEventQueryChanged(final String query)
List<Object?> get props => [query];
}
/// The person adds [word] to the studyset.
final class const DictionaryEventWordAdded(final DictionaryWord word)
/// The person adds [word] (or a character) to the studyset.
final class const DictionaryEventWordAdded(final DictionaryItem word)
extends DictionaryEvent {
@override
List<Object?> get props => [word];

View file

@ -4,14 +4,15 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// What adding words came to: how many became cards, and how many the
/// What adding entries came to: how many became cards, and how many the
/// studyset held already.
typedef DictionaryAdded = ({int added, int skipped});
/// Adds dictionary words to a studyset as vocabulary cards, each once: a
/// word a card of the studyset was made from already is left out. Each card
/// is tagged with the word's [DictionaryWord.tagNames]: its JLPT level and
/// its word class.
/// Adds dictionary entries to a studyset as cards (vocabulary for a word, a
/// character card for a character), each once: an entry a card of the
/// studyset was made from already is left out. Each card is tagged with
/// the entry's [DictionaryItem.tagNames]: a word's JLPT level and word
/// class, a character's level, school grade or script.
///
/// Adds run one at a time, so two at once (a word tapped twice, a word
/// while a level is added) never make the same card twice. Which entries a
@ -34,12 +35,12 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
};
/// Adds [words] to [studysetId], at its end, in their order.
Future<DictionaryAdded> add(String studysetId, List<DictionaryWord> words) =>
Future<DictionaryAdded> add(String studysetId, List<DictionaryItem> words) =>
_queue.run(() => _add(studysetId, words));
Future<DictionaryAdded> _add(
String studysetId,
List<DictionaryWord> words,
List<DictionaryItem> words,
) async {
final there = await _entriesIn(studysetId);
final fresh = [

View file

@ -6,15 +6,16 @@ abstract interface class DictionaryGateway() {
/// The dictionaries the instance holds.
Future<List<Dictionary>> dictionaries();
/// The words of [dictionary] that [query] finds, best first.
Future<List<DictionaryWord>> search(Dictionary dictionary, String query);
/// The entries of [dictionary] that [query] finds, best first: words or
/// characters, as it holds.
Future<List<DictionaryItem>> search(Dictionary dictionary, String query);
/// How many words of [dictionary] carry [label].
/// How many entries of [dictionary] carry [label].
Future<int> countLabelled(Dictionary dictionary, String label);
/// The words of [dictionary] that carry [label], in its order: [limit] of
/// them after the first [offset].
Future<List<DictionaryWord>> labelled(
/// The entries of [dictionary] that carry [label], in its order: [limit]
/// of them after the first [offset].
Future<List<DictionaryItem>> labelled(
Dictionary dictionary,
String label, {
required int offset,

View file

@ -41,24 +41,24 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
ofLanguage.firstOrNull;
}
/// The words of [dictionary] that [query] finds, best first.
Future<List<DictionaryWord>> search(Dictionary dictionary, String query) =>
/// The entries of [dictionary] that [query] finds, best first.
Future<List<DictionaryItem>> search(Dictionary dictionary, String query) =>
query.trim().isEmpty
? Future.value(const [])
: _gateway.search(dictionary, query);
/// How many words of [dictionary] carry [label] (`jlpt:n5`).
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
Future<int> countLabelled(Dictionary dictionary, String label) =>
_gateway.countLabelled(dictionary, label);
/// Every word of [dictionary] that carries [label], in its order, a page
/// Every entry of [dictionary] that carries [label], in its order, a page
/// at a time; [onProgress] hears how many have come so far.
Future<List<DictionaryWord>> allLabelled(
Future<List<DictionaryItem>> allLabelled(
Dictionary dictionary,
String label, {
void Function(int count)? onProgress,
}) async {
final words = <DictionaryWord>[];
final words = <DictionaryItem>[];
while (true) {
final next = await _gateway.labelled(
dictionary,

View file

@ -32,7 +32,7 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
];
@override
Future<List<DictionaryWord>> search(
Future<List<DictionaryItem>> search(
Dictionary dictionary,
String query,
) async => [
@ -41,7 +41,7 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
dictionary.glossLanguage,
query,
))
_word(hit),
_item(dictionary, hit),
];
@override
@ -53,7 +53,7 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
);
@override
Future<List<DictionaryWord>> labelled(
Future<List<DictionaryItem>> labelled(
Dictionary dictionary,
String label, {
required int offset,
@ -66,14 +66,24 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
offset: offset,
limit: limit,
))
_word(hit),
_item(dictionary, hit),
];
DictionaryWord _word(protocol.DictionaryHit hit) => DictionaryWord.fromEntry(
ref: (source: hit.source, id: hit.sourceId),
headword: hit.headword,
reading: hit.reading,
labels: hit.labels,
entry: hit.entry,
);
/// [hit] as a word or a character, as [dictionary] holds.
DictionaryItem _item(Dictionary dictionary, protocol.DictionaryHit hit) =>
switch (dictionary.kind) {
DictionaryKind.words => DictionaryWord.fromEntry(
ref: (source: hit.source, id: hit.sourceId),
headword: hit.headword,
reading: hit.reading,
labels: hit.labels,
entry: hit.entry,
),
DictionaryKind.characters => DictionaryCharacter.fromEntry(
ref: (source: hit.source, id: hit.sourceId),
headword: hit.headword,
labels: hit.labels,
entry: hit.entry,
),
};
}

View file

@ -52,22 +52,47 @@ final class const Dictionary({
/// there (`食べて`), and its translation into English.
typedef WordExample = ({String form, String japanese, String? english});
/// An entry of a dictionary as the app shows it and turns it into a card:
/// a word, or a character.
sealed class const DictionaryItem() with Equatable {
/// The dictionary entry it is, for good: a card keeps it.
DictionaryRef get ref;
/// The word, or the character.
String get headword;
/// What it can be picked by: `common`, `jlpt:n5`, `grade:1`, …
List<String> get labels;
/// It as a card of its kind.
CardContent get card;
/// The tags a card made from it gets, wherever it is made.
List<String> get tagNames;
/// Its JLPT level, 5 for N5 to 1 for N1; null when it has none.
int? get jlpt => [
for (final label in labels)
if (RegExp(r'^jlpt:n([1-5])$').firstMatch(label) case final match?)
int.parse(match[1]!),
].firstOrNull;
}
/// A word of a dictionary: what it shows and turns into a card.
final class const DictionaryWord({
/// The dictionary entry it is, for good: a card keeps it.
required final DictionaryRef ref,
required final String headword,
@override required final DictionaryRef ref,
@override required final String headword,
/// How it is read; null when the headword is its own reading.
required final String? reading,
/// `common`, `jlpt:n5`, …
required final List<String> labels,
@override required final List<String> labels,
required final List<VocabularySense> senses,
/// Sentences using it, its first meaning's first.
final List<WordExample> examples = const [],
}) with Equatable {
}) extends DictionaryItem {
/// The word from its [entry] as the dictionary file holds it (JSON).
factory fromEntry({
required DictionaryRef ref,
@ -119,17 +144,11 @@ final class const DictionaryWord({
/// How many of its [examples] a card made from it takes as hints.
static const examplesOnCard = 3;
/// Its JLPT level, 5 for N5 to 1 for N1; null when it has none.
int? get jlpt => [
for (final label in labels)
if (RegExp(r'^jlpt:n([1-5])$').firstMatch(label) case final match?)
int.parse(match[1]!),
].firstOrNull;
/// The tags a card made from it gets, wherever it is made: its JLPT level
/// (`JLPT-N5`) and its word class by its first sense (JMdict's codes,
/// grouped: `Verb`, `Noun`, …), in English as the dictionary's codes are,
/// so a level or a class can be studied alone.
@override
List<String> get tagNames => [
if (jlpt case final level?) 'JLPT-N$level',
...{
@ -141,6 +160,7 @@ final class const DictionaryWord({
/// The word as a vocabulary card: its headword on the front, its reading
/// (none for a word written in kana) and senses, its first [examplesOnCard]
/// sentences as hints ([exampleHints]), and where it came from.
@override
CardContent get card => CardContent(
type: CardType.vocabulary,
front: RichContent.plain(headword),
@ -161,6 +181,113 @@ final class const DictionaryWord({
List<Object?> get props => [ref, headword, reading, labels, senses, examples];
}
/// A character of a dictionary of characters: a kanji with its meanings,
/// readings, school grade, stroke count and frequency, or a kana with its
/// romaji.
final class const DictionaryCharacter({
@override required final DictionaryRef ref,
/// The character.
@override required final String headword,
/// `jlpt:n5`, `grade:1`, `grade:secondary`, `hiragana`, `katakana`.
@override required final List<String> labels,
final List<String> meanings = const [],
/// Its on readings, in katakana.
final List<String> onReadings = const [],
/// Its kun readings, in hiragana, the okurigana after a dot.
final List<String> kunReadings = const [],
/// A kana's romaji; null for a kanji.
final String? romaji,
/// The school grade it is taught in: 1 to 6, 8 for secondary school.
final int? grade,
final int? strokes,
/// Its place among the 2,500 most used in newspapers.
final int? frequency,
}) extends DictionaryItem {
/// The character from its [entry] as the dictionary file holds it (JSON).
factory fromEntry({
required DictionaryRef ref,
required String headword,
required List<String> labels,
required String entry,
}) {
final json = jsonDecode(entry);
final fields = json is Map<String, Object?>
? json
: const <String, Object?>{};
List<String> strings(Object? value) => [
if (value is List)
for (final item in value) item.toString(),
];
int? number(Object? value) => value is int ? value : null;
return DictionaryCharacter(
ref: ref,
headword: headword,
labels: labels,
meanings: strings(fields['meanings']),
onReadings: strings(fields['on']),
kunReadings: strings(fields['kun']),
romaji: fields['romaji'] as String?,
grade: number(fields['grade']),
strokes: number(fields['strokes']),
frequency: number(fields['frequency']),
);
}
/// Whether it is a kana, read as its [romaji].
bool get isKana => romaji != null;
/// What it means: a kana its romaji.
List<String> get meaningsShown => [?romaji, ...meanings];
/// The tags a card made from it gets: its JLPT level (`JLPT-N5`), its
/// school grade (`Grade 1`, `Secondary school`) or its script
/// (`Hiragana`, `Katakana`), in English as a word's are.
@override
List<String> get tagNames => [
if (jlpt case final level?) 'JLPT-N$level',
if (grade case final grade? when grade >= 1 && grade <= 6) 'Grade $grade',
if (grade == 8) _secondarySchool,
if (labels.contains('hiragana')) 'Hiragana',
if (labels.contains('katakana')) 'Katakana',
];
/// The character as a character card: its meanings (a kana's romaji) and
/// readings, and where it came from.
@override
CardContent get card => CardContent(
type: CardType.character,
front: RichContent.plain(headword),
back: RichContent.empty(),
meanings: meaningsShown,
onReadings: onReadings,
kunReadings: kunReadings,
entry: ref,
).normalized;
@override
List<Object?> get props => [
ref,
headword,
labels,
meanings,
onReadings,
kunReadings,
romaji,
grade,
strokes,
frequency,
];
}
const _secondarySchool = 'Secondary school';
/// [example] as a hint: the sentence, the word in it in bold, and its
/// English on a line below.
RichContent _hint(WordExample example) {
@ -202,15 +329,19 @@ RichContent _hint(WordExample example) {
});
}
/// Whether [name] is a tag a dictionary word brings, whatever its case: a
/// JLPT level or a word class. Looking another word up for a card replaces
/// these, as they belong to the word.
bool isWordTagName(String name) =>
_wordTagNames.contains(canonicalTagName(name));
/// Whether [name] is a tag a dictionary entry brings, whatever its case: a
/// JLPT level, a word class, a school grade or a script. Looking another
/// entry up for a card replaces these, as they belong to the entry.
bool isDictionaryTagName(String name) =>
_dictionaryTagNames.contains(canonicalTagName(name));
final _wordTagNames = {
final _dictionaryTagNames = {
for (final name in [
for (var level = 1; level <= 5; level++) 'JLPT-N$level',
for (var grade = 1; grade <= 6; grade++) 'Grade $grade',
_secondarySchool,
'Hiragana',
'Katakana',
'Noun',
'Pronoun',
'i-Adjective',

View file

@ -11,18 +11,18 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_w
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Looks a word up in [dictionary], starting from [query] (what the card
/// says so far), to write a card with: completes with the word chosen, or
/// with null.
/// Looks a word or a character up in [dictionary], starting from [query]
/// (what the card says so far), to write a card with: completes with the
/// entry chosen, or with null.
///
/// Words a card of the studyset was made from already ([inStudyset]) are
/// Entries a card of the studyset was made from already ([inStudyset]) are
/// marked so: they can still be chosen.
Future<DictionaryWord?> lookUpWord(
Future<DictionaryItem?> lookUp(
BuildContext context,
Dictionary dictionary, {
String query = '',
Set<DictionaryRef> inStudyset = const {},
}) => showAdaptiveSheet<DictionaryWord>(
}) => showAdaptiveSheet<DictionaryItem>(
context,
builder: (context) =>
_LookUp(dictionary: dictionary, query: query, inStudyset: inStudyset),
@ -40,7 +40,7 @@ class const _LookUp({
class _LookUpState() extends State<_LookUp> {
late final _query = TextEditingController(text: widget.query);
Timer? _debounce;
List<DictionaryWord>? _results;
List<DictionaryItem>? _results;
var _unreachable = false;
/// The query whose results show, so a slower earlier one doesn't win.
@ -98,7 +98,10 @@ class _LookUpState() extends State<_LookUp> {
autofocus: widget.query.isEmpty,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: l10n.dictionarySearchHint,
hintText: switch (widget.dictionary.kind) {
DictionaryKind.words => l10n.dictionarySearchHint,
DictionaryKind.characters => l10n.dictionaryCharacterSearchHint,
},
prefixIcon: const Icon(LucideIcons.search),
border: const OutlineInputBorder(),
isDense: true,
@ -137,12 +140,12 @@ class _LookUpState() extends State<_LookUp> {
),
)
else if (results != null)
for (final (index, word) in results.indexed) ...[
for (final (index, item) in results.indexed) ...[
if (index > 0) const Divider(height: 1),
DictionaryWordTile(
word: word,
onTap: () => Navigator.of(context).pop(word),
trailing: widget.inStudyset.contains(word.ref)
dictionaryItemTile(
item,
onTap: () => Navigator.of(context).pop(item),
trailing: widget.inStudyset.contains(item.ref)
? Tooltip(
message: l10n.dictionaryInStudyset,
child: Icon(

View file

@ -15,8 +15,10 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The dictionary of the studyset [studysetId], to add words from as
/// flashcards: one by one from a search, or a whole level at once. Its
/// credits at its foot, as its licence asks.
/// flashcards: one by one from a search, or a whole level at once; beside
/// it, where the instance has one, the dictionary of its characters, kanji
/// and kana, in a tab of its own. Each with its credits at its foot, as
/// its licence asks.
class const DictionaryScreen({required final String studysetId, super.key})
extends StatelessWidget {
@override
@ -38,34 +40,78 @@ class const DictionaryScreen({required final String studysetId, super.key})
},
),
),
builder: (context, dictionary) => Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context).dictionaryTitle)),
body: switch (dictionary) {
null => const Center(child: AppLoadingIndicator()),
final dictionary => BlocProvider(
create: (context) {
final person = PersonScope.of(context);
return DictionaryBloc(
DependenciesScope.of(context).dictionaries,
DictionaryAdder(person.cards, person.tags),
dictionary,
studysetId,
)..add(const DictionaryEventStarted());
},
child: _DictionaryView(dictionary: dictionary),
),
builder: (context, words) => StudysetDictionary(
kind: DictionaryKind.characters,
builder: (context, characters) {
final l10n = AppLocalizations.of(context);
return switch ((words, characters)) {
(null, _) => Scaffold(
appBar: AppBar(title: Text(l10n.dictionaryTitle)),
body: const Center(child: AppLoadingIndicator()),
),
(final words?, null) => Scaffold(
appBar: AppBar(title: Text(l10n.dictionaryTitle)),
body: _Dictionary(dictionary: words, studysetId: studysetId),
),
(final words?, final characters?) => DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Text(l10n.dictionaryTitle),
bottom: TabBar(
tabs: [
Tab(text: l10n.dictionaryTabWords),
Tab(text: l10n.dictionaryTabCharacters),
],
),
),
body: TabBarView(
children: [
_Dictionary(dictionary: words, studysetId: studysetId),
_Dictionary(dictionary: characters, studysetId: studysetId),
],
),
),
),
};
},
),
);
}
/// One dictionary, to search and add from.
class const _Dictionary({
required final Dictionary dictionary,
required final String studysetId,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) {
final person = PersonScope.of(context);
return DictionaryBloc(
DependenciesScope.of(context).dictionaries,
DictionaryAdder(person.cards, person.tags),
dictionary,
studysetId,
)..add(const DictionaryEventStarted());
},
child: _DictionaryView(dictionary: dictionary),
);
}
class const _DictionaryView({required final Dictionary dictionary})
extends StatefulWidget {
@override
State<_DictionaryView> createState() => _DictionaryViewState();
}
class _DictionaryViewState() extends State<_DictionaryView> {
/// Kept alive while the other tab shows, with what was searched.
class _DictionaryViewState()
extends State<_DictionaryView>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
final _query = TextEditingController();
Timer? _debounce;
@ -88,8 +134,10 @@ class _DictionaryViewState() extends State<_DictionaryView> {
@override
Widget build(BuildContext context) {
super.build(context);
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final characters = widget.dictionary.kind == DictionaryKind.characters;
return BlocListener<DictionaryBloc, DictionaryState>(
listenWhen: (before, now) => now.outcome != null || now.failed,
listener: (context, state) {
@ -104,7 +152,10 @@ class _DictionaryViewState() extends State<_DictionaryView> {
final message = word != null && added.added == 1
? l10n.dictionaryAdded(word)
: [
l10n.dictionaryWordsAdded(added.added),
if (characters)
l10n.dictionaryCharactersAdded(added.added)
else
l10n.dictionaryWordsAdded(added.added),
if (added.skipped > 0)
l10n.dictionaryWordsSkipped(added.skipped),
].join(' ');
@ -125,7 +176,9 @@ class _DictionaryViewState() extends State<_DictionaryView> {
autofocus: true,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: l10n.dictionarySearchHint,
hintText: characters
? l10n.dictionaryCharacterSearchHint
: l10n.dictionarySearchHint,
prefixIcon: const Icon(LucideIcons.search),
border: const OutlineInputBorder(),
isDense: true,
@ -190,8 +243,8 @@ class const _Results({required final DictionaryState state})
separatorBuilder: (context, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final word = state.results[index];
return DictionaryWordTile(
word: word,
return dictionaryItemTile(
word,
trailing: state.inStudyset.contains(word.ref)
? Tooltip(
message: l10n.dictionaryInStudyset,
@ -225,7 +278,11 @@ class const _Levels({
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.dictionaryConfirmTitle(count)),
title: Text(
dictionary.kind == DictionaryKind.characters
? l10n.dictionaryConfirmCharactersTitle(count)
: l10n.dictionaryConfirmTitle(count),
),
content: Text(l10n.dictionaryConfirmMessage),
actions: [
TextButton(
@ -258,13 +315,54 @@ class const _Levels({
final level = state.level;
final count = state.levelCounts[level] ?? 0;
final adding = state.adding;
final characters = dictionary.kind == DictionaryKind.characters;
Widget chips(Iterable<String> labels) => Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final label in labels)
if ((state.levelCounts[label] ?? 0) > 0)
ChoiceChip(
label: Text(levelName(l10n, label)),
selected: label == level,
onSelected: adding != null
? null
: (_) => context.read<DictionaryBloc>().add(
DictionaryEventLevelChosen(label),
),
),
],
);
// A dictionary's characters come in groups, each under its name: the
// kana, the JLPT levels, the school grades.
final groups = characters
? [
for (final (name, prefix) in [
(l10n.dictionaryGroupKana, null),
(l10n.dictionaryGroupJlpt, 'jlpt:'),
(l10n.dictionaryGroupGrades, 'grade:'),
])
(
name: name,
labels: [
for (final label in levelLabelsOf(dictionary))
if (prefix == null
? !label.contains(':')
: label.startsWith(prefix))
label,
],
),
]
: [(name: null, labels: levelLabelsOf(dictionary))];
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
Text(l10n.dictionaryLevels, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.dictionaryLevelsHint,
characters
? l10n.dictionaryCharacterLevelsHint
: l10n.dictionaryLevelsHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@ -273,23 +371,15 @@ class const _Levels({
if (state.levelCounts.isEmpty)
const Center(child: AppLoadingIndicator())
else ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final label in levelLabelsOf(dictionary))
if ((state.levelCounts[label] ?? 0) > 0)
ChoiceChip(
label: Text(levelName(l10n, label)),
selected: label == level,
onSelected: adding != null
? null
: (_) => context.read<DictionaryBloc>().add(
DictionaryEventLevelChosen(label),
),
),
for (final (index, (:name, :labels)) in groups.indexed)
if (labels.any((label) => (state.levelCounts[label] ?? 0) > 0)) ...[
if (index > 0) const SizedBox(height: 12),
if (name != null) ...[
Text(name, style: theme.textTheme.labelLarge),
const SizedBox(height: 8),
],
chips(labels),
],
),
const SizedBox(height: 16),
if (adding case (:final done, count: final total)) ...[
// Every word come, the cards take a moment more to be made.
@ -308,7 +398,11 @@ class const _Levels({
child: FilledButton.icon(
onPressed: count == 0 ? null : () => _add(context, count),
icon: const Icon(LucideIcons.plus),
label: Text(l10n.dictionaryAddLevel(count)),
label: Text(
characters
? l10n.dictionaryAddCharacters(count)
: l10n.dictionaryAddLevel(count),
),
),
),
],
@ -317,9 +411,19 @@ class const _Levels({
}
}
/// A level's name: `N5` for a JLPT level, "Common" for the common words.
/// A level's name: `N5` for a JLPT level, "Common" for the common words,
/// "Grade 1" for a school grade, and the kana's scripts by their names.
String levelName(AppLocalizations l10n, String label) => switch (label) {
'common' => l10n.dictionaryLabelCommon,
'hiragana' => l10n.dictionaryLabelHiragana,
'katakana' => l10n.dictionaryLabelKatakana,
'grade:secondary' => l10n.dictionaryLabelSecondary,
_ when label.startsWith('grade:') => switch (int.tryParse(
label.substring(6),
)) {
final grade? => l10n.dictionaryGrade(grade),
null => label,
},
_ when label.startsWith('jlpt:') => label.substring(5).toUpperCase(),
_ => label,
};

View file

@ -65,11 +65,107 @@ class const DictionaryWordTile({
}
}
/// The credits of [dictionary] in a line, as its licence asks to name them.
String dictionaryCreditLine(AppLocalizations l10n, Dictionary dictionary) =>
l10n.dictionaryCredits(
[
for (final credit in dictionary.credits)
'${credit.name} (${credit.by}, ${credit.license})',
].join('; '),
/// A character as a dictionary lists it: large at its start, its meanings
/// (a kana's romaji), its on and kun readings, and its level, school grade
/// and strokes; [trailing] at its end, and [onTap] for the whole row.
class const DictionaryCharacterTile({
required final DictionaryCharacter character,
final Widget? trailing,
final VoidCallback? onTap,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final readings = [
character.onReadings.join('、'),
character.kunReadings.join('、'),
].where((part) => part.isNotEmpty).join(' · ');
final marks = [
if (character.jlpt case final level?) 'N$level',
if (character.grade case final grade? when grade >= 1 && grade <= 6)
l10n.dictionaryGrade(grade),
if (character.grade == 8) l10n.dictionaryLabelSecondary,
if (character.strokes case final strokes?)
l10n.dictionaryStrokes(strokes),
];
return ListTile(
onTap: onTap,
leading: SizedBox(
width: 48,
child: Center(
child: Text(
character.headword,
style: theme.textTheme.headlineMedium,
),
),
),
title: Text(
character.meaningsShown.take(4).join('; '),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (readings.isNotEmpty)
Text(
readings,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: muted),
),
if (marks.isNotEmpty) ...[
const SizedBox(height: 4),
Wrap(
spacing: 6,
children: [
for (final mark in marks)
Text(
mark,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
],
],
),
trailing: trailing,
);
}
}
/// [item] as its kind is listed: a word or a character.
Widget dictionaryItemTile(
DictionaryItem item, {
Widget? trailing,
VoidCallback? onTap,
}) => switch (item) {
final DictionaryWord word => DictionaryWordTile(
word: word,
trailing: trailing,
onTap: onTap,
),
final DictionaryCharacter character => DictionaryCharacterTile(
character: character,
trailing: trailing,
onTap: onTap,
),
};
/// The credits of [dictionary] in a line, as its licence asks to name them.
String dictionaryCreditLine(AppLocalizations l10n, Dictionary dictionary) {
final sources = [
for (final credit in dictionary.credits)
'${credit.name} (${credit.by}, ${credit.license})',
].join('; ');
return switch (dictionary.kind) {
DictionaryKind.words => l10n.dictionaryCredits(sources),
DictionaryKind.characters => l10n.dictionaryCharacterCredits(sources),
};
}

View file

@ -27,7 +27,8 @@ enum DictionaryStatus() {
}
/// [builder]'s widget with the dictionary of the studyset around: the one
/// for its languages (`DictionaryRepository.dictionaryFor`); null while it
/// of [kind] (words unless given) for its languages
/// (`DictionaryRepository.dictionaryFor`); null while it
/// is being found, when there is none, or when the instance can't be
/// reached. So whatever uses a dictionary shows only where there is one.
/// [missing], when given, stands in for [builder] without a dictionary, and
@ -44,6 +45,7 @@ class const StudysetDictionary({
VoidCallback retry,
)?
missing,
final DictionaryKind kind = DictionaryKind.words,
super.key,
}) extends StatefulWidget {
@override
@ -68,13 +70,25 @@ class _StudysetDictionaryState() extends State<StudysetDictionary> {
}
}
@override
void didUpdateWidget(StudysetDictionary oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.kind != oldWidget.kind) {
_dictionary = null;
unawaited(_find());
}
}
Future<void> _find() async {
final asked = ++_asked;
final dictionaries = DependenciesScope.of(context).dictionaries;
final languages = _languages!;
_status = DictionaryStatus.finding;
try {
final dictionary = await dictionaries.dictionaryFor(languages);
final dictionary = await dictionaries.dictionaryFor(
languages,
kind: widget.kind,
);
if (mounted && asked == _asked) {
setState(() {
_dictionary = dictionary;

View file

@ -27,6 +27,68 @@ final terebi = word(
);
void main() {
group('a character', () {
test('reads its meanings, readings, grade, strokes and frequency from '
'the entry', () {
final read = DictionaryCharacter.fromEntry(
ref: (source: 'kanjidic', id: '食'),
headword: '食',
labels: ['jlpt:n5', 'grade:2'],
entry:
'{"meanings":["eat","food"],"on":["ショク","ジキ"],'
'"kun":["く.う","た.べる"],"grade":2,"strokes":9,'
'"frequency":328,"jlpt":5}',
);
expect(read.meanings, ['eat', 'food']);
expect(read.onReadings, ['ショク', 'ジキ']);
expect(read.kunReadings, ['く.う', 'た.べる']);
expect((read.grade, read.strokes, read.frequency), (2, 9, 328));
expect(read.jlpt, 5);
expect(read.isKana, isFalse);
});
test('becomes a character card that keeps where it is from; a kana '
'means its romaji', () {
final eat = character(
'食',
meanings: ['eat'],
on: ['ショク'],
kun: ['た.べる'],
).card;
expect(eat.type, CardType.character);
expect(eat.front.plainText.trim(), '食');
expect(eat.meanings, ['eat']);
expect(eat.onReadings, ['ショク']);
expect(eat.kunReadings, ['た.べる']);
expect(eat.entry, (source: 'kanjidic', id: '食'));
expect(eat.isComplete, isTrue);
final a = character('あ', labels: ['hiragana'], romaji: 'a').card;
expect(a.meanings, ['a']);
expect(a.isComplete, isTrue);
});
test('names the tags by JLPT level, school grade and script', () {
expect(
character('食', labels: ['jlpt:n5', 'grade:2'], grade: 2).tagNames,
['JLPT-N5', 'Grade 2'],
);
expect(
character(
'亜',
labels: ['jlpt:n1', 'grade:secondary'],
grade: 8,
).tagNames,
['JLPT-N1', 'Secondary school'],
);
expect(character('ア', labels: ['katakana']).tagNames, ['Katakana']);
expect(isDictionaryTagName('grade 2'), isTrue);
expect(isDictionaryTagName('Hiragana'), isTrue);
expect(isDictionaryTagName('Kanji I like'), isFalse);
});
});
group('a word', () {
test('reads its senses from the entry, the codes with them', () {
final read = DictionaryWord.fromEntry(

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
@ -36,6 +37,23 @@ final toukyou = word(
pos: ['n'],
);
final shoku = character(
'食',
meanings: ['eat', 'food'],
on: ['ショク'],
kun: ['た.べる'],
labels: ['jlpt:n5', 'grade:2'],
grade: 2,
strokes: 9,
);
final a = character('あ', labels: ['hiragana'], romaji: 'a');
/// An instance with JMdict and KANJIDIC2.
FakeDictionaryGateway withKanji() => FakeDictionaryGateway(
available: [jmdict, kanjidic],
words: [taberu, terebi, shoku, a],
);
void main() {
late FakeCardGateway cardGateway;
late CardRepository cards;
@ -203,6 +221,72 @@ void main() {
);
});
testWidgets('beside the words, kanji and kana: their levels in groups, '
'one added as character cards tagged with level and grade', (
tester,
) async {
await show(tester, dictionaries: withKanji());
await addMaterial(tester, 'From the dictionary');
expect(find.text('Words'), findsOneWidget);
await tester.tap(find.text('Kanji & kana'));
await settle(tester);
expect(find.text('Kana'), findsOneWidget);
expect(find.text('JLPT levels'), findsOneWidget);
expect(find.text('School grades'), findsOneWidget);
expect(find.text('Grade 2'), findsOneWidget);
await tester.tap(find.text('N5').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Add 1 character'));
await tester.pumpAndSettle();
expect(find.text('Added 1 character.'), findsOneWidget);
final made = (await cards.cardsOf('japanese')).single;
expect(made.content.type, CardType.character);
expect(made.content.onReadings, ['ショク']);
expect(made.content.entry, (source: 'kanjidic', id: '食'));
});
testWidgets('a character found shows its readings and strokes, and is '
'added as a card', (tester) async {
await show(tester, dictionaries: withKanji());
await addMaterial(tester, 'From the dictionary');
await tester.tap(find.text('Kanji & kana'));
await settle(tester);
await tester.enterText(find.byType(TextField).hitTestable(), 'ショク');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.text('ショク · た.べる'), findsOneWidget);
expect(find.text('9 strokes'), findsOneWidget);
await tester.tap(find.text('Add').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Added “食”.'), findsOneWidget);
});
testWidgets('Look up fills a character card with its meanings, readings '
'and tags', (tester) async {
await show(tester, dictionaries: withKanji());
await addMaterial(tester, 'Create flashcards');
await tester.tap(find.text('Character'));
await settle(tester);
await tester.tap(find.byTooltip('Look up in the dictionary'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'eat');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.text('eat; food'));
await settle(tester);
expect(find.widgetWithText(TextField, 'eat'), findsOneWidget);
expect(find.widgetWithText(TextField, 'ショク'), findsOneWidget);
expect(find.widgetWithText(TextField, 'た.べる'), findsOneWidget);
expect(find.text('JLPT-N5'), findsOneWidget);
expect(find.text('Grade 2'), findsOneWidget);
});
testWidgets('Look up fills a vocabulary card with a word of the '
'dictionary', (tester) async {
await show(tester);

View file

@ -58,12 +58,36 @@ DictionaryWord word(
senses: [VocabularySense(glosses: glosses, pos: pos)],
);
/// A KANJIDIC2 character: [headword] meaning [meanings], read [on] and
/// [kun], with [labels]; a kana with its [romaji].
DictionaryCharacter character(
String headword, {
List<String> meanings = const [],
List<String> on = const [],
List<String> kun = const [],
List<String> labels = const [],
String? romaji,
int? grade,
int? strokes,
}) => DictionaryCharacter(
ref: (source: 'kanjidic', id: headword),
headword: headword,
labels: labels,
meanings: meanings,
onReadings: on,
kunReadings: kun,
romaji: romaji,
grade: grade,
strokes: strokes,
);
/// A [DictionaryGateway] in memory: an instance with the dictionaries
/// [available], each holding its [words] in their order. A search finds the words whose
/// headword, reading or a meaning starts with the query.
/// [available], holding the [words] (and characters) of each source in
/// their order. A search finds the entries whose headword, reading, a
/// meaning or a reading starts with the query.
class FakeDictionaryGateway({
final List<Dictionary> available = const [jmdict],
final List<DictionaryWord> words = const [],
final List<DictionaryItem> words = const [],
}) implements DictionaryGateway {
/// When set, every request fails with it.
Exception? fail;
@ -80,8 +104,12 @@ class FakeDictionaryGateway({
return available;
}
/// The entries of [dictionary].
Iterable<DictionaryItem> _of(Dictionary dictionary) =>
words.where((word) => word.ref.source == dictionary.source);
@override
Future<List<DictionaryWord>> search(
Future<List<DictionaryItem>> search(
Dictionary dictionary,
String query,
) async {
@ -90,29 +118,38 @@ class FakeDictionaryGateway({
}
final typed = query.trim().toLowerCase();
return [
for (final word in words)
if ([
word.headword,
?word.reading,
for (final sense in word.senses) ...sense.glosses,
].any((key) => key.toLowerCase().startsWith(typed)))
word,
for (final item in _of(dictionary))
if (switch (item) {
DictionaryWord() => [
item.headword,
?item.reading,
for (final sense in item.senses) ...sense.glosses,
],
DictionaryCharacter() => [
item.headword,
...item.meaningsShown,
...item.onReadings,
for (final reading in item.kunReadings) reading.replaceAll('.', ''),
],
}.any((key) => key.toLowerCase().startsWith(typed)))
item,
];
}
@override
Future<int> countLabelled(Dictionary dictionary, String label) async =>
words.where((word) => word.labels.contains(label)).length;
_of(dictionary).where((word) => word.labels.contains(label)).length;
@override
Future<List<DictionaryWord>> labelled(
Future<List<DictionaryItem>> labelled(
Dictionary dictionary,
String label, {
required int offset,
required int limit,
}) async => words
.where((word) => word.labels.contains(label))
.skip(offset)
.take(limit)
.toList();
}) async =>
_of(dictionary)
.where((word) => word.labels.contains(label))
.skip(offset)
.take(limit)
.toList();
}