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
21 changed files with 1034 additions and 158 deletions

View file

@ -144,7 +144,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start, those whose SHA-256 changed, each source replaced in one transaction (`dictionary_loader.dart`). A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryWord.tagNames`), and a word looked up again on that card swaps the tags the word before brought.
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start, those whose SHA-256 changed, each source replaced in one transaction (`dictionary_loader.dart`). A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters.
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
- **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`.
- **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. A study session's Undo takes back its steps last first (ratings, burials and deletions alike, across its rounds), so it holds the cards it deletes (`hold`) until it ends, when they go after the usual time to undo; a card deleted in a session that never ended (the app killed) stays. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied). **A character card** (`CardType.character`, card schema 7, decided 2026-10-09: one type for kanji and kana) holds the character on its front, its `meanings` (a kana's romaji is its meaning, so kana are studied and written as kanji are) and a kanji's `onReadings` and `kunReadings` as dictionaries write them (katakana; hiragana with the okurigana after a dot); its JLPT level and school grade are tags, its strokes the app's own (KanjiVG).

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);

Some files were not shown because too many files have changed in this diff Show more