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