feat(dictionary): the radicals, each listing the kanji written with it
Beside Words and Kanji & kana, a Radicals tab lists the 214 Kangxi radicals by their strokes, with the forms Japanese writes them as inside kanji (亻, 氵, 艹, …). One chosen lists the kanji KANJIDIC2 files under it, in the dictionary's order, to add or to see how one is written; the tab shares the characters' bloc, so a kanji added there shows as held in a search too. The curator labels each kanji with its classical radical (`radical:N`), so the server's existing label queries serve the tab unchanged. Its files now say which conversion made them (`.conversion`): an instance running a curator that converts differently converts again at once, rather than at its weekly update, so the radicals appear right after a deploy. 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
e04be6603d
commit
fb0ef7cdab
19 changed files with 1027 additions and 111 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -27,6 +27,7 @@ deploy/.env
|
|||
# Dictionary files an instance loads: made by halkyon_learn_curator, not kept in git.
|
||||
deploy/dictionaries/*.jsonl
|
||||
deploy/dictionaries/*.jsonl.gz
|
||||
deploy/dictionaries/.conversion
|
||||
|
||||
# Snaps built by `snapcraft pack` in the repository root.
|
||||
/*.snap
|
||||
|
|
|
|||
|
|
@ -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), `write` (by hand, 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 and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. 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. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. 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.
|
||||
- **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 and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. 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. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. 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. Beside them a Radicals tab lists the 214 Kangxi radicals by their strokes (`feature/kanji/model/radical.dart`: the ideograph each of Unicode's Kangxi radicals stands for, the forms Japanese writes it as inside kanji, Unicode's English name), each listing the kanji KANJIDIC2 files under it (its classical radical, the label `radical:N`, which the curator writes), to add or see; that tab shares the characters' bloc, so what it adds shows as held in a search too. The curator stamps its files with how it converted them (`.conversion`, `conversion` in its `update.dart`): raise it whenever it writes something else from the same download, and instances convert again as soon as they run the new curator rather than up to a week later.
|
||||
- **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. **How characters are written** (`feature/kanji/`, decided 2026-10-09: bundled, so writing works offline) is KanjiVG's, in `assets/kanjivg/` (CC BY-SA 3.0, its notice in the licence list): the curator's `curate kanjivg` writes one gzipped JSON file per 256 code points (2.7 MB in all), which `StrokeRepository` (app-wide in `Dependencies`, as the maps) reads when a character of it is first asked for; the strokes are SVG path data in a 109 × 109 square, read with `path_parsing` (pure Dart, so `CharacterStrokes` flattens them into points without `dart:ui`). `StrokeOrderView` traces them in order; `showCharacterSheet` shows a character with its stroke order, meanings, readings and components, from a studied character's answer and from the dictionary's characters. A studied character's answer also lists the words written with it (`example_word.dart`: up to five vocabulary cards of the studysets the session studies, whatever tags narrow it, read with its cards, so offline), each opening its card. **Writing by hand** (decided 2026-10-09, after Kanji Dojo's practice): the study option Write by hand (`StudyOptions.writeByHand`, settings schema 7) has character cards and words written in the session, mode `write` on a schedule of its own (words so written are neither quizzed nor typed). `WritingCanvas` takes raw pointers and claims every touch on it at once (an `EagerGestureRecognizer`), so a stroke never scrolls or swipes to rate; each stroke is checked as it ends (`checkStroke`, `kanji/model/stroke_check.dart`: both resampled to 22 points, shape compared once centred, position, length and direction, against what a loose hand strays by; a stroke closer to one still to come is out of order), a right one drawn as KanjiVG draws it, and the hand's offset from the middle forgiven in the strokes after. Mistakes are strokes that didn't count and hints asked for; two misses in a row show the next stroke faintly. `WritingBody` writes a word character by character, filling in those without a stroke order, and once written (or given up on, Show it) outlines the rating the mistakes suggest (`suggestedRating`: none Good, one or two OK, more Bad; given up, Bad). `stroke_check_test.dart` holds the checker to real strokes, moved, wavering, larger and smaller, reversed and out of order: change its weights only with that test passing.
|
||||
|
|
|
|||
|
|
@ -70,7 +70,11 @@ server never reads half a file; should anything fail, the files stay as they
|
|||
were. Some 50 seconds and 400 MB. With `--every 7` it keeps running and updates
|
||||
whenever the files are a week old, as their dates say (a restart doesn't
|
||||
download again), and tries again six hours after a failure. An instance runs it
|
||||
so in a container of its own (the server's README).
|
||||
so in a container of its own (the server's README). Beside the files,
|
||||
`.conversion` says how the curator converted them (`conversion` in
|
||||
`lib/src/update.dart`): raise it whenever the curator writes something else
|
||||
from the same download (a label, a field), and an instance converts again as
|
||||
soon as the new curator runs, not a week later.
|
||||
|
||||
## The format (`halkyon-dictionary`, version 1)
|
||||
|
||||
|
|
@ -93,7 +97,7 @@ The **first line** says what the file holds:
|
|||
|---|---|
|
||||
| `id` | Its id in the source; with the source and meaning language, it names the entry for good. |
|
||||
| `headword`, `reading` | How the word is shown, and how it is read; no `reading` when the headword is its own (a word written in kana). |
|
||||
| `labels` | What it can be picked by: `common`, `jlpt:n5` … `jlpt:n1` (later e.g. `hsk:3`, `topik:1`); for characters also the school grade (`grade:1` … `grade:6`, `grade:secondary` for the rest of the Jōyō kanji) and the script (`hiragana`, `katakana`). |
|
||||
| `labels` | What it can be picked by: `common`, `jlpt:n5` … `jlpt:n1` (later e.g. `hsk:3`, `topik:1`); for characters also the school grade (`grade:1` … `grade:6`, `grade:secondary` for the rest of the Jōyō kanji), a kanji's Kangxi radical (`radical:1` … `radical:214`, KANJIDIC2's classical one) and the script (`hiragana`, `katakana`). |
|
||||
| `rank` | Its order among the source's entries, lower first; JMdict: N5 to N1 in their lists' order, then other common words, then the rest; KANJIDIC2: hiragana and katakana in the kana table's order, then kanji by JLPT level, then by school grade, then the rest, each by frequency. |
|
||||
| `keys` | What a search matches, lower case: written forms, readings in their own script and romanised, meanings. Everything specific to a language happens here, so the server's search knows no language. |
|
||||
| `looseKeys` | What a search matches after every `keys` match: spellings people type that aren't quite right (romaji with long vowels typed once, `sushi` for すうし). Left out when empty. |
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ Future<Map<String, int>> readKanjiJlpt(File file) async {
|
|||
/// readings, meanings, school grade, stroke count and frequency, and its JLPT
|
||||
/// level from [jlpt] (N5 to N1 as 5 to 1, by the character); then hiragana
|
||||
/// and katakana with their romaji. Labels pick a level (`jlpt:n5`), a school
|
||||
/// grade (`grade:1` … `grade:6`, `grade:secondary`) or a script (`hiragana`,
|
||||
/// grade (`grade:1` … `grade:6`, `grade:secondary`), a kanji's classical
|
||||
/// radical (`radical:1` … `radical:214`) or a script (`hiragana`,
|
||||
/// `katakana`); kanji rank by JLPT level, then grade, then frequency.
|
||||
Iterable<String> kanjidicDictionary(
|
||||
String text, {
|
||||
|
|
@ -94,6 +95,17 @@ DictionaryEntry? kanjiEntry(
|
|||
final strokes = number('stroke_count');
|
||||
final frequency = number('freq');
|
||||
final level = jlpt[literal];
|
||||
// The Kangxi radical it is filed under; KANJIDIC2 also gives Nelson's.
|
||||
final radical = int.tryParse(
|
||||
character
|
||||
.getElement('radical')
|
||||
?.findElements('rad_value')
|
||||
.where((value) => value.getAttribute('rad_type') == 'classical')
|
||||
.firstOrNull
|
||||
?.innerText
|
||||
.trim() ??
|
||||
'',
|
||||
);
|
||||
|
||||
final groups =
|
||||
character
|
||||
|
|
@ -150,6 +162,7 @@ DictionaryEntry? kanjiEntry(
|
|||
8 => 'grade:secondary',
|
||||
_ => null,
|
||||
},
|
||||
if (radical != null && radical >= 1 && radical <= 214) 'radical:$radical',
|
||||
],
|
||||
rank: switch ((level, grade)) {
|
||||
(final level?, _) => (6 - level) * 10000 + (frequency ?? 9999),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ final kanjidicSource = Uri.parse(
|
|||
/// The dictionary files an update writes, by name.
|
||||
const updatedFiles = ['jmdict-en.jsonl.gz', 'kanjidic-en.jsonl.gz'];
|
||||
|
||||
/// How the curator converts the sources now. Raise it whenever a new
|
||||
/// curator writes something else from the same download (a label, a field),
|
||||
/// so that an instance converts again as soon as the new curator runs
|
||||
/// rather than at its next update, up to a week later. 2: kanji labelled
|
||||
/// by their radical.
|
||||
const conversion = 2;
|
||||
|
||||
/// The file in the dictionaries' folder that says which [conversion] made
|
||||
/// them. The server reads only dictionary files (`.jsonl`, `.jsonl.gz`).
|
||||
const conversionFile = '.conversion';
|
||||
|
||||
/// Fetches [uri] into [file].
|
||||
typedef Fetch = Future<void> Function(Uri uri, File file);
|
||||
|
||||
|
|
@ -43,7 +54,8 @@ Future<void> httpFetch(Uri uri, File file) async {
|
|||
/// JLPT levels from the curator's [data] folder (`data/README.md`). Each
|
||||
/// file is written beside its place first and then put there at once, so
|
||||
/// the server never reads half of one; should anything fail, the files
|
||||
/// there stay as they were. Returns the files written.
|
||||
/// there stay as they were. Once all are there, [conversionFile] says how
|
||||
/// they were made. Returns the files written.
|
||||
Future<List<File>> updateDictionaries({
|
||||
required Directory into,
|
||||
required Directory data,
|
||||
|
|
@ -76,9 +88,11 @@ Future<List<File>> updateDictionaries({
|
|||
),
|
||||
);
|
||||
await into.create(recursive: true);
|
||||
return [
|
||||
final written = [
|
||||
for (final file in [jmdict, kanjidic]) await _putInPlace(file, into),
|
||||
];
|
||||
await File('${into.path}/$conversionFile').writeAsString('$conversion');
|
||||
return written;
|
||||
} finally {
|
||||
await downloads.delete(recursive: true);
|
||||
}
|
||||
|
|
@ -103,8 +117,13 @@ Future<File> _putInPlace(File file, Directory folder) async {
|
|||
}
|
||||
|
||||
/// When the dictionary files in [folder] were last brought up to date: the
|
||||
/// oldest of them; null when one is missing.
|
||||
/// oldest of them; null when one is missing, or when another [conversion]
|
||||
/// made them (or one from before the curator said).
|
||||
DateTime? lastUpdated(Directory folder) {
|
||||
final made = File('${folder.path}/$conversionFile');
|
||||
if (!made.existsSync() || made.readAsStringSync().trim() != '$conversion') {
|
||||
return null;
|
||||
}
|
||||
DateTime? oldest;
|
||||
for (final name in updatedFiles) {
|
||||
final file = File('${folder.path}/$name');
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ void main() {
|
|||
final eat = entry('食');
|
||||
expect(eat['headword'], '食');
|
||||
expect(eat['reading'], isNull);
|
||||
expect(eat['labels'], ['jlpt:n5', 'grade:2']);
|
||||
expect(eat['labels'], ['jlpt:n5', 'grade:2', 'radical:184']);
|
||||
expect(fields('食'), {
|
||||
'meanings': ['eat', 'food'],
|
||||
'on': ['ショク', 'ジキ'],
|
||||
|
|
@ -84,10 +84,11 @@ void main() {
|
|||
expect(entry('一')['looseKeys'], isNot(contains('ひと')));
|
||||
});
|
||||
|
||||
test('levels and grades label the kanji and rank them: JLPT level, '
|
||||
'then school grade, then frequency', () {
|
||||
expect(entry('亜')['labels'], ['jlpt:n1', 'grade:secondary']);
|
||||
expect(entry('唖')['labels'], isEmpty);
|
||||
test('levels, grades and radicals label the kanji; level and grade rank '
|
||||
'them: JLPT level, then school grade, then frequency', () {
|
||||
expect(entry('亜')['labels'], ['jlpt:n1', 'grade:secondary', 'radical:7']);
|
||||
// KANJIDIC2's classical radical, not Nelson's.
|
||||
expect(entry('唖')['labels'], ['radical:30']);
|
||||
final ranks = [
|
||||
for (final kanji in ['一', '食', '亜', '唖']) entry(kanji)['rank']! as int,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -47,15 +47,31 @@ void main() {
|
|||
['JMdict', 'JLPT vocabulary lists', 'Tatoeba'],
|
||||
);
|
||||
expect(header('kanjidic-en.jsonl.gz')['kind'], 'characters');
|
||||
// Nothing half-written left behind.
|
||||
// Nothing half-written left behind; which conversion made them.
|
||||
expect(
|
||||
[for (final entity in into.listSync()) entity.uri.pathSegments.last]
|
||||
..sort(),
|
||||
['jmdict-en.jsonl.gz', 'kanjidic-en.jsonl.gz'],
|
||||
['.conversion', 'jmdict-en.jsonl.gz', 'kanjidic-en.jsonl.gz'],
|
||||
);
|
||||
expect(File('${into.path}/.conversion').readAsStringSync(), '$conversion');
|
||||
expect(lastUpdated(into), isNotNull);
|
||||
});
|
||||
|
||||
test('files another conversion made, or one from before the curator said, '
|
||||
'are converted again at once', () async {
|
||||
await updateDictionaries(
|
||||
into: into,
|
||||
data: Directory('data'),
|
||||
fetch: samples,
|
||||
);
|
||||
final made = File('${into.path}/$conversionFile')
|
||||
..writeAsStringSync('${conversion - 1}');
|
||||
expect(lastUpdated(into), isNull);
|
||||
|
||||
made.deleteSync();
|
||||
expect(lastUpdated(into), isNull);
|
||||
});
|
||||
|
||||
test('a download that fails leaves the files there as they were', () async {
|
||||
await updateDictionaries(
|
||||
into: into,
|
||||
|
|
|
|||
|
|
@ -567,6 +567,14 @@
|
|||
"dictionaryGroupGrades": "Schuljahre",
|
||||
"dictionaryStrokes": "{count, plural, =1{1 Strich} other{{count} Striche}}",
|
||||
"@dictionaryStrokes": {"placeholders": {"count": {"type": "int"}}},
|
||||
"dictionaryTabRadicals": "Radikale",
|
||||
"dictionaryRadicalsHint": "Die 214 Radikale, nach denen Wörterbücher Kanji ordnen, nach ihrer Strichzahl gruppiert. Wähle eins für die Kanji, die damit geschrieben werden.",
|
||||
"dictionaryAllRadicals": "Alle Radikale",
|
||||
"dictionaryRadicalNumber": "Radikal {number}",
|
||||
"@dictionaryRadicalNumber": {"placeholders": {"number": {"type": "int"}}},
|
||||
"dictionaryRadicalKanjiCount": "{count} Kanji",
|
||||
"@dictionaryRadicalKanjiCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"dictionaryRadicalNone": "Das Wörterbuch der Instanz hat unter diesem Radikal noch keine Kanji.",
|
||||
"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"}}},
|
||||
|
|
|
|||
|
|
@ -623,6 +623,14 @@
|
|||
"dictionaryGroupGrades": "School grades",
|
||||
"dictionaryStrokes": "{count, plural, =1{1 stroke} other{{count} strokes}}",
|
||||
"@dictionaryStrokes": {"placeholders": {"count": {"type": "int"}}},
|
||||
"dictionaryTabRadicals": "Radicals",
|
||||
"dictionaryRadicalsHint": "The 214 radicals that dictionaries order kanji by, grouped by their strokes. Choose one for the kanji written with it.",
|
||||
"dictionaryAllRadicals": "All radicals",
|
||||
"dictionaryRadicalNumber": "Radical {number}",
|
||||
"@dictionaryRadicalNumber": {"placeholders": {"number": {"type": "int"}}},
|
||||
"dictionaryRadicalKanjiCount": "{count} kanji",
|
||||
"@dictionaryRadicalKanjiCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"dictionaryRadicalNone": "The instance's dictionary has no kanji under this radical yet.",
|
||||
"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"}}},
|
||||
|
|
|
|||
|
|
@ -3406,6 +3406,42 @@ abstract class AppLocalizations {
|
|||
/// **'{count, plural, =1{1 stroke} other{{count} strokes}}'**
|
||||
String dictionaryStrokes(int count);
|
||||
|
||||
/// No description provided for @dictionaryTabRadicals.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radicals'**
|
||||
String get dictionaryTabRadicals;
|
||||
|
||||
/// No description provided for @dictionaryRadicalsHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The 214 radicals that dictionaries order kanji by, grouped by their strokes. Choose one for the kanji written with it.'**
|
||||
String get dictionaryRadicalsHint;
|
||||
|
||||
/// No description provided for @dictionaryAllRadicals.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All radicals'**
|
||||
String get dictionaryAllRadicals;
|
||||
|
||||
/// No description provided for @dictionaryRadicalNumber.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Radical {number}'**
|
||||
String dictionaryRadicalNumber(int number);
|
||||
|
||||
/// No description provided for @dictionaryRadicalKanjiCount.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} kanji'**
|
||||
String dictionaryRadicalKanjiCount(int count);
|
||||
|
||||
/// No description provided for @dictionaryRadicalNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The instance\'s dictionary has no kanji under this radical yet.'**
|
||||
String get dictionaryRadicalNone;
|
||||
|
||||
/// No description provided for @dictionaryCharacterLevelsHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -2042,6 +2042,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dictionaryTabRadicals => 'Radikale';
|
||||
|
||||
@override
|
||||
String get dictionaryRadicalsHint =>
|
||||
'Die 214 Radikale, nach denen Wörterbücher Kanji ordnen, nach ihrer Strichzahl gruppiert. Wähle eins für die Kanji, die damit geschrieben werden.';
|
||||
|
||||
@override
|
||||
String get dictionaryAllRadicals => 'Alle Radikale';
|
||||
|
||||
@override
|
||||
String dictionaryRadicalNumber(int number) {
|
||||
return 'Radikal $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String dictionaryRadicalKanjiCount(int count) {
|
||||
return '$count Kanji';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dictionaryRadicalNone =>
|
||||
'Das Wörterbuch der Instanz hat unter diesem Radikal noch keine Kanji.';
|
||||
|
||||
@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.';
|
||||
|
|
|
|||
|
|
@ -2029,6 +2029,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dictionaryTabRadicals => 'Radicals';
|
||||
|
||||
@override
|
||||
String get dictionaryRadicalsHint =>
|
||||
'The 214 radicals that dictionaries order kanji by, grouped by their strokes. Choose one for the kanji written with it.';
|
||||
|
||||
@override
|
||||
String get dictionaryAllRadicals => 'All radicals';
|
||||
|
||||
@override
|
||||
String dictionaryRadicalNumber(int number) {
|
||||
return 'Radical $number';
|
||||
}
|
||||
|
||||
@override
|
||||
String dictionaryRadicalKanjiCount(int count) {
|
||||
return '$count kanji';
|
||||
}
|
||||
|
||||
@override
|
||||
String get dictionaryRadicalNone =>
|
||||
'The instance\'s dictionary has no kanji under this radical yet.';
|
||||
|
||||
@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.';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/kanji/model/radical.dart';
|
||||
|
||||
/// The labels a whole level is added by, in the order offered: for
|
||||
/// Japanese words the JLPT levels, easiest first, then the other common
|
||||
|
|
@ -40,8 +41,9 @@ List<String> levelLabelsOf(Dictionary dictionary) => switch (dictionary) {
|
|||
};
|
||||
|
||||
/// Looking words up in [_dictionary] and adding them to the studyset
|
||||
/// [_studysetId] as flashcards: one by one from a search, or a whole level
|
||||
/// at once. Knows which words the studyset holds already.
|
||||
/// [_studysetId] as flashcards: one by one from a search or from a
|
||||
/// radical's kanji, or a whole level at once. Knows which words the
|
||||
/// studyset holds already.
|
||||
class DictionaryBloc(
|
||||
final DictionaryRepository _dictionaries,
|
||||
final DictionaryAdder _adder,
|
||||
|
|
@ -56,6 +58,7 @@ class DictionaryBloc(
|
|||
(event, emit) => emit(state.copyWith(level: event.label)),
|
||||
);
|
||||
on<DictionaryEventLevelAdded>(_onLevelAdded);
|
||||
on<DictionaryEventRadicalChosen>(_onRadicalChosen);
|
||||
}
|
||||
|
||||
Future<void> _onStarted(
|
||||
|
|
@ -181,6 +184,34 @@ class DictionaryBloc(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onRadicalChosen(
|
||||
DictionaryEventRadicalChosen event,
|
||||
Emitter<DictionaryState> emit,
|
||||
) async {
|
||||
final number = event.number;
|
||||
emit(
|
||||
state.copyWith(radical: number, radicalKanji: null, radicalFailed: false),
|
||||
);
|
||||
if (number == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final kanji = await _dictionaries.allLabelled(
|
||||
_dictionary,
|
||||
radicalLabel(number),
|
||||
);
|
||||
// A radical chosen since wins, whichever comes back first.
|
||||
if (state.radical == number) {
|
||||
emit(state.copyWith(radicalKanji: kanji));
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Listing a radical failed', name: 'Dictionary', error: error);
|
||||
if (state.radical == number) {
|
||||
emit(state.copyWith(radicalFailed: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What adding words came to, and the word when it was one.
|
||||
|
|
@ -188,7 +219,8 @@ typedef DictionaryOutcome = ({DictionaryAdded added, String? word});
|
|||
|
||||
/// The dictionary screen: the search and what it found, which words the
|
||||
/// studyset holds, the levels and how many words each has, the level
|
||||
/// chosen, how far adding one is, and what the last adding came to.
|
||||
/// chosen, how far adding one is, the radical chosen with its kanji, and
|
||||
/// what the last adding came to.
|
||||
final class const DictionaryState({
|
||||
final String query = '',
|
||||
final List<DictionaryItem> results = const [],
|
||||
|
|
@ -203,10 +235,20 @@ final class const DictionaryState({
|
|||
/// Adding words failed just now; cleared by the next change, as
|
||||
/// [outcome] is.
|
||||
final bool failed = false,
|
||||
|
||||
/// The radical chosen (its number, 1 to 214); null showing them all.
|
||||
final int? radical,
|
||||
|
||||
/// The chosen radical's kanji, in the dictionary's order; null until
|
||||
/// they come.
|
||||
final List<DictionaryItem>? radicalKanji,
|
||||
|
||||
/// Whether listing the chosen radical's kanji failed.
|
||||
final bool radicalFailed = false,
|
||||
}) with Equatable {
|
||||
/// This state with some fields replaced; [adding] and [level] can be
|
||||
/// cleared by passing null on purpose, [outcome] and [failed] are
|
||||
/// replaced each time.
|
||||
/// This state with some fields replaced; [adding], [radical] and
|
||||
/// [radicalKanji] can be cleared by passing null on purpose, [outcome]
|
||||
/// and [failed] are replaced each time.
|
||||
DictionaryState copyWith({
|
||||
String? query,
|
||||
List<DictionaryItem>? results,
|
||||
|
|
@ -218,6 +260,9 @@ final class const DictionaryState({
|
|||
Object? adding = _keep,
|
||||
DictionaryOutcome? outcome,
|
||||
bool failed = false,
|
||||
Object? radical = _keep,
|
||||
Object? radicalKanji = _keep,
|
||||
bool? radicalFailed,
|
||||
}) => DictionaryState(
|
||||
query: query ?? this.query,
|
||||
results: results ?? this.results,
|
||||
|
|
@ -231,6 +276,11 @@ final class const DictionaryState({
|
|||
: adding as ({int done, int count})?,
|
||||
outcome: outcome,
|
||||
failed: failed,
|
||||
radical: identical(radical, _keep) ? this.radical : radical as int?,
|
||||
radicalKanji: identical(radicalKanji, _keep)
|
||||
? this.radicalKanji
|
||||
: radicalKanji as List<DictionaryItem>?,
|
||||
radicalFailed: radicalFailed ?? this.radicalFailed,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -245,6 +295,9 @@ final class const DictionaryState({
|
|||
adding,
|
||||
outcome,
|
||||
failed,
|
||||
radical,
|
||||
radicalKanji,
|
||||
radicalFailed,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -283,3 +336,11 @@ final class const DictionaryEventLevelChosen(final String label)
|
|||
|
||||
/// The person adds every word of the level chosen.
|
||||
final class const DictionaryEventLevelAdded() extends DictionaryEvent;
|
||||
|
||||
/// The person chooses the radical [number] (1 to 214) to see its kanji;
|
||||
/// null goes back to them all.
|
||||
final class const DictionaryEventRadicalChosen(final int? number)
|
||||
extends DictionaryEvent {
|
||||
@override
|
||||
List<Object?> get props => [number];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_add
|
|||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/kanji/model/radical.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -18,8 +19,9 @@ 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; 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.
|
||||
/// and kana, in a tab of its own, and the radicals in another, each
|
||||
/// listing the kanji written with it. Each with its credits at its foot,
|
||||
/// as its licence asks.
|
||||
class const DictionaryScreen({required final String studysetId, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -52,10 +54,14 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
),
|
||||
(final words?, null) => Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.dictionaryTitle)),
|
||||
body: _Dictionary(dictionary: words, studysetId: studysetId),
|
||||
body: _Dictionary(
|
||||
dictionary: words,
|
||||
studysetId: studysetId,
|
||||
child: _DictionaryView(dictionary: words),
|
||||
),
|
||||
),
|
||||
(final words?, final characters?) => DefaultTabController(
|
||||
length: 2,
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.dictionaryTitle),
|
||||
|
|
@ -63,27 +69,41 @@ class const DictionaryScreen({required final String studysetId, super.key})
|
|||
tabs: [
|
||||
Tab(text: l10n.dictionaryTabWords),
|
||||
Tab(text: l10n.dictionaryTabCharacters),
|
||||
Tab(text: l10n.dictionaryTabRadicals),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
// The characters and the radicals share one bloc: a kanji
|
||||
// added from its radical shows as added in a search too.
|
||||
body: _Dictionary(
|
||||
dictionary: characters,
|
||||
studysetId: studysetId,
|
||||
child: TabBarView(
|
||||
children: [
|
||||
_Dictionary(dictionary: words, studysetId: studysetId),
|
||||
_Dictionary(dictionary: characters, studysetId: studysetId),
|
||||
_Dictionary(
|
||||
dictionary: words,
|
||||
studysetId: studysetId,
|
||||
child: _DictionaryView(dictionary: words),
|
||||
),
|
||||
_DictionaryView(dictionary: characters),
|
||||
_Radicals(dictionary: characters),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One dictionary, to search and add from.
|
||||
/// One dictionary to search and add from, for [child], which says what
|
||||
/// adding came to.
|
||||
class const _Dictionary({
|
||||
required final Dictionary dictionary,
|
||||
required final String studysetId,
|
||||
required final Widget child,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
|
|
@ -96,7 +116,34 @@ class const _Dictionary({
|
|||
studysetId,
|
||||
)..add(const DictionaryEventStarted());
|
||||
},
|
||||
child: _DictionaryView(dictionary: dictionary),
|
||||
child: BlocListener<DictionaryBloc, DictionaryState>(
|
||||
listenWhen: (before, now) => now.outcome != null || now.failed,
|
||||
listener: (context, state) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final outcome = state.outcome;
|
||||
if (outcome == null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.dictionaryAddFailed)));
|
||||
return;
|
||||
}
|
||||
final (:added, :word) = outcome;
|
||||
final message = word != null && added.added == 1
|
||||
? l10n.dictionaryAdded(word)
|
||||
: [
|
||||
if (dictionary.kind == DictionaryKind.characters)
|
||||
l10n.dictionaryCharactersAdded(added.added)
|
||||
else
|
||||
l10n.dictionaryWordsAdded(added.added),
|
||||
if (added.skipped > 0)
|
||||
l10n.dictionaryWordsSkipped(added.skipped),
|
||||
].join(' ');
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -137,38 +184,9 @@ class _DictionaryViewState()
|
|||
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) {
|
||||
final outcome = state.outcome;
|
||||
if (outcome == null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(l10n.dictionaryAddFailed)));
|
||||
return;
|
||||
}
|
||||
final (:added, :word) = outcome;
|
||||
final message = word != null && added.added == 1
|
||||
? l10n.dictionaryAdded(word)
|
||||
: [
|
||||
if (characters)
|
||||
l10n.dictionaryCharactersAdded(added.added)
|
||||
else
|
||||
l10n.dictionaryWordsAdded(added.added),
|
||||
if (added.skipped > 0)
|
||||
l10n.dictionaryWordsSkipped(added.skipped),
|
||||
].join(' ');
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
return _Page(
|
||||
dictionary: widget.dictionary,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
|
|
@ -197,11 +215,32 @@ class _DictionaryViewState()
|
|||
: _Results(state: state),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A dictionary's page: [children] above, its credits at its foot, within
|
||||
/// a readable width.
|
||||
class const _Page({
|
||||
required final Dictionary dictionary,
|
||||
required final List<Widget> children,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: readableContentMaxWidth),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
...children,
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Text(
|
||||
dictionaryCreditLine(l10n, widget.dictionary),
|
||||
dictionaryCreditLine(AppLocalizations.of(context), dictionary),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -210,7 +249,6 @@ class _DictionaryViewState()
|
|||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -239,14 +277,26 @@ class const _Results({required final DictionaryState state})
|
|||
),
|
||||
);
|
||||
}
|
||||
return _Items(items: state.results, state: state);
|
||||
}
|
||||
}
|
||||
|
||||
/// [items] to add, or marked as in the studyset; a character shows how it
|
||||
/// is written when tapped.
|
||||
class const _Items({
|
||||
required final List<DictionaryItem> items,
|
||||
required final DictionaryState state,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return ListView.separated(
|
||||
itemCount: state.results.length,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (context, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final word = state.results[index];
|
||||
final word = items[index];
|
||||
return dictionaryItemTile(
|
||||
word,
|
||||
// A character shows how it is written.
|
||||
onTap: switch (word) {
|
||||
final DictionaryCharacter character => () => showCharacterSheet(
|
||||
context,
|
||||
|
|
@ -426,6 +476,238 @@ class const _Levels({
|
|||
}
|
||||
}
|
||||
|
||||
/// The 214 radicals by their strokes, each opening the kanji written with
|
||||
/// it (KANJIDIC2's classical radical), to add or see how one is written.
|
||||
class const _Radicals({required final Dictionary dictionary})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<DictionaryBloc, DictionaryState>(
|
||||
builder: (context, state) => _Page(
|
||||
dictionary: dictionary,
|
||||
children: [
|
||||
Expanded(
|
||||
child: switch (state.radical) {
|
||||
final number? => _RadicalKanji(
|
||||
radical: radicals[number - 1],
|
||||
state: state,
|
||||
),
|
||||
null => const _RadicalList(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Every radical, under its stroke count.
|
||||
class const _RadicalList() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final byStrokes = <int, List<Radical>>{};
|
||||
for (final radical in radicals) {
|
||||
(byStrokes[radical.strokes] ??= []).add(radical);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
Text(
|
||||
l10n.dictionaryRadicalsHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
for (final MapEntry(key: strokes, value: group)
|
||||
in byStrokes.entries) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.dictionaryStrokes(strokes),
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final radical in group)
|
||||
_RadicalTile(
|
||||
radical: radical,
|
||||
onTap: () => context.read<DictionaryBloc>().add(
|
||||
DictionaryEventRadicalChosen(radical.number),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A radical as the list shows it: its form, its number below, and the
|
||||
/// forms it takes inside kanji in a corner.
|
||||
class const _RadicalTile({
|
||||
required final Radical radical,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Tooltip(
|
||||
message: radical.name,
|
||||
child: Material(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
// Every tile the same size, so the rows line up: the forms the
|
||||
// radical takes inside kanji go small in a corner.
|
||||
child: SizedBox.square(
|
||||
dimension: 56,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
radical.character,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
Text(
|
||||
'${radical.number}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (radical.variants.isNotEmpty)
|
||||
PositionedDirectional(
|
||||
top: 3,
|
||||
end: 5,
|
||||
child: Text(
|
||||
radical.variants.join(),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A radical's form in [style], the forms it takes inside kanji beside it
|
||||
/// a little smaller.
|
||||
class const _RadicalForms({
|
||||
required final Radical radical,
|
||||
required final TextStyle? style,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: radical.character, style: style),
|
||||
for (final variant in radical.variants)
|
||||
TextSpan(
|
||||
text: ' $variant',
|
||||
style: style?.copyWith(
|
||||
fontSize: (style?.fontSize ?? 22) * 0.75,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The kanji written with [radical], in the dictionary's order, and a way
|
||||
/// back to every radical.
|
||||
class const _RadicalKanji({
|
||||
required final Radical radical,
|
||||
required final DictionaryState state,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final kanji = state.radicalKanji;
|
||||
void choose(int? number) => context.read<DictionaryBloc>().add(
|
||||
DictionaryEventRadicalChosen(number),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 8, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.dictionaryAllRadicals,
|
||||
onPressed: () => choose(null),
|
||||
icon: const Icon(LucideIcons.arrowLeft),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_RadicalForms(
|
||||
radical: radical,
|
||||
style: theme.textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(radical.name, style: theme.textTheme.titleMedium),
|
||||
Text(
|
||||
[
|
||||
l10n.dictionaryRadicalNumber(radical.number),
|
||||
l10n.dictionaryStrokes(radical.strokes),
|
||||
if (kanji != null && kanji.isNotEmpty)
|
||||
l10n.dictionaryRadicalKanjiCount(kanji.length),
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: switch (kanji) {
|
||||
null when state.radicalFailed => _Unreachable(
|
||||
onRetry: () => choose(radical.number),
|
||||
),
|
||||
null => const Center(child: AppLoadingIndicator()),
|
||||
[] => Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
l10n.dictionaryRadicalNone,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
final kanji => _Items(items: kanji, state: state),
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
|
|
|||
283
halkyon_learn_flutter/lib/src/feature/kanji/model/radical.dart
Normal file
283
halkyon_learn_flutter/lib/src/feature/kanji/model/radical.dart
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
/// One of the 214 Kangxi radicals, by which dictionaries of Chinese
|
||||
/// characters have ordered them since 1716: its `number`, the `character`
|
||||
/// as a radical stands alone (the CJK ideograph that Unicode's Kangxi
|
||||
/// radical `U+2F00 + number - 1` stands for), the `variants` Japanese
|
||||
/// writes it as inside other kanji, its `strokes` and its English `name`
|
||||
/// (Unicode's).
|
||||
typedef Radical = ({
|
||||
int number,
|
||||
String character,
|
||||
List<String> variants,
|
||||
int strokes,
|
||||
String name,
|
||||
});
|
||||
|
||||
/// The 214 Kangxi radicals, in their order; by stroke count too.
|
||||
const radicals = <Radical>[
|
||||
(number: 1, character: '一', variants: [], strokes: 1, name: 'one'),
|
||||
(number: 2, character: '丨', variants: [], strokes: 1, name: 'line'),
|
||||
(number: 3, character: '丶', variants: [], strokes: 1, name: 'dot'),
|
||||
(number: 4, character: '丿', variants: [], strokes: 1, name: 'slash'),
|
||||
(number: 5, character: '乙', variants: [], strokes: 1, name: 'second'),
|
||||
(number: 6, character: '亅', variants: [], strokes: 1, name: 'hook'),
|
||||
(number: 7, character: '二', variants: [], strokes: 2, name: 'two'),
|
||||
(number: 8, character: '亠', variants: [], strokes: 2, name: 'lid'),
|
||||
(number: 9, character: '人', variants: ['亻'], strokes: 2, name: 'man'),
|
||||
(number: 10, character: '儿', variants: [], strokes: 2, name: 'legs'),
|
||||
(number: 11, character: '入', variants: [], strokes: 2, name: 'enter'),
|
||||
(number: 12, character: '八', variants: [], strokes: 2, name: 'eight'),
|
||||
(number: 13, character: '冂', variants: [], strokes: 2, name: 'down box'),
|
||||
(number: 14, character: '冖', variants: [], strokes: 2, name: 'cover'),
|
||||
(number: 15, character: '冫', variants: [], strokes: 2, name: 'ice'),
|
||||
(number: 16, character: '几', variants: [], strokes: 2, name: 'table'),
|
||||
(number: 17, character: '凵', variants: [], strokes: 2, name: 'open box'),
|
||||
(number: 18, character: '刀', variants: ['刂'], strokes: 2, name: 'knife'),
|
||||
(number: 19, character: '力', variants: [], strokes: 2, name: 'power'),
|
||||
(number: 20, character: '勹', variants: [], strokes: 2, name: 'wrap'),
|
||||
(number: 21, character: '匕', variants: [], strokes: 2, name: 'spoon'),
|
||||
(
|
||||
number: 22,
|
||||
character: '匚',
|
||||
variants: [],
|
||||
strokes: 2,
|
||||
name: 'right open box',
|
||||
),
|
||||
(
|
||||
number: 23,
|
||||
character: '匸',
|
||||
variants: [],
|
||||
strokes: 2,
|
||||
name: 'hiding enclosure',
|
||||
),
|
||||
(number: 24, character: '十', variants: [], strokes: 2, name: 'ten'),
|
||||
(number: 25, character: '卜', variants: [], strokes: 2, name: 'divination'),
|
||||
(number: 26, character: '卩', variants: [], strokes: 2, name: 'seal'),
|
||||
(number: 27, character: '厂', variants: [], strokes: 2, name: 'cliff'),
|
||||
(number: 28, character: '厶', variants: [], strokes: 2, name: 'private'),
|
||||
(number: 29, character: '又', variants: [], strokes: 2, name: 'again'),
|
||||
(number: 30, character: '口', variants: [], strokes: 3, name: 'mouth'),
|
||||
(number: 31, character: '囗', variants: [], strokes: 3, name: 'enclosure'),
|
||||
(number: 32, character: '土', variants: [], strokes: 3, name: 'earth'),
|
||||
(number: 33, character: '士', variants: [], strokes: 3, name: 'scholar'),
|
||||
(number: 34, character: '夂', variants: [], strokes: 3, name: 'go'),
|
||||
(number: 35, character: '夊', variants: [], strokes: 3, name: 'go slowly'),
|
||||
(number: 36, character: '夕', variants: [], strokes: 3, name: 'evening'),
|
||||
(number: 37, character: '大', variants: [], strokes: 3, name: 'big'),
|
||||
(number: 38, character: '女', variants: [], strokes: 3, name: 'woman'),
|
||||
(number: 39, character: '子', variants: [], strokes: 3, name: 'child'),
|
||||
(number: 40, character: '宀', variants: [], strokes: 3, name: 'roof'),
|
||||
(number: 41, character: '寸', variants: [], strokes: 3, name: 'inch'),
|
||||
(number: 42, character: '小', variants: [], strokes: 3, name: 'small'),
|
||||
(number: 43, character: '尢', variants: [], strokes: 3, name: 'lame'),
|
||||
(number: 44, character: '尸', variants: [], strokes: 3, name: 'corpse'),
|
||||
(number: 45, character: '屮', variants: [], strokes: 3, name: 'sprout'),
|
||||
(number: 46, character: '山', variants: [], strokes: 3, name: 'mountain'),
|
||||
(number: 47, character: '巛', variants: ['川'], strokes: 3, name: 'river'),
|
||||
(number: 48, character: '工', variants: [], strokes: 3, name: 'work'),
|
||||
(number: 49, character: '己', variants: [], strokes: 3, name: 'oneself'),
|
||||
(number: 50, character: '巾', variants: [], strokes: 3, name: 'turban'),
|
||||
(number: 51, character: '干', variants: [], strokes: 3, name: 'dry'),
|
||||
(number: 52, character: '幺', variants: [], strokes: 3, name: 'short thread'),
|
||||
(number: 53, character: '广', variants: [], strokes: 3, name: 'dotted cliff'),
|
||||
(number: 54, character: '廴', variants: [], strokes: 3, name: 'long stride'),
|
||||
(number: 55, character: '廾', variants: [], strokes: 3, name: 'two hands'),
|
||||
(number: 56, character: '弋', variants: [], strokes: 3, name: 'shoot'),
|
||||
(number: 57, character: '弓', variants: [], strokes: 3, name: 'bow'),
|
||||
(number: 58, character: '彐', variants: [], strokes: 3, name: 'snout'),
|
||||
(number: 59, character: '彡', variants: [], strokes: 3, name: 'bristle'),
|
||||
(number: 60, character: '彳', variants: [], strokes: 3, name: 'step'),
|
||||
(number: 61, character: '心', variants: ['忄'], strokes: 4, name: 'heart'),
|
||||
(number: 62, character: '戈', variants: [], strokes: 4, name: 'halberd'),
|
||||
(number: 63, character: '戶', variants: ['戸'], strokes: 4, name: 'door'),
|
||||
(number: 64, character: '手', variants: ['扌'], strokes: 4, name: 'hand'),
|
||||
(number: 65, character: '支', variants: [], strokes: 4, name: 'branch'),
|
||||
(number: 66, character: '攴', variants: ['攵'], strokes: 4, name: 'rap'),
|
||||
(number: 67, character: '文', variants: [], strokes: 4, name: 'script'),
|
||||
(number: 68, character: '斗', variants: [], strokes: 4, name: 'dipper'),
|
||||
(number: 69, character: '斤', variants: [], strokes: 4, name: 'axe'),
|
||||
(number: 70, character: '方', variants: [], strokes: 4, name: 'square'),
|
||||
(number: 71, character: '无', variants: [], strokes: 4, name: 'not'),
|
||||
(number: 72, character: '日', variants: [], strokes: 4, name: 'sun'),
|
||||
(number: 73, character: '曰', variants: [], strokes: 4, name: 'say'),
|
||||
(number: 74, character: '月', variants: [], strokes: 4, name: 'moon'),
|
||||
(number: 75, character: '木', variants: [], strokes: 4, name: 'tree'),
|
||||
(number: 76, character: '欠', variants: [], strokes: 4, name: 'lack'),
|
||||
(number: 77, character: '止', variants: [], strokes: 4, name: 'stop'),
|
||||
(number: 78, character: '歹', variants: [], strokes: 4, name: 'death'),
|
||||
(number: 79, character: '殳', variants: [], strokes: 4, name: 'weapon'),
|
||||
(number: 80, character: '毋', variants: [], strokes: 4, name: 'do not'),
|
||||
(number: 81, character: '比', variants: [], strokes: 4, name: 'compare'),
|
||||
(number: 82, character: '毛', variants: [], strokes: 4, name: 'fur'),
|
||||
(number: 83, character: '氏', variants: [], strokes: 4, name: 'clan'),
|
||||
(number: 84, character: '气', variants: [], strokes: 4, name: 'steam'),
|
||||
(number: 85, character: '水', variants: ['氵'], strokes: 4, name: 'water'),
|
||||
(number: 86, character: '火', variants: ['灬'], strokes: 4, name: 'fire'),
|
||||
(number: 87, character: '爪', variants: ['爫'], strokes: 4, name: 'claw'),
|
||||
(number: 88, character: '父', variants: [], strokes: 4, name: 'father'),
|
||||
(number: 89, character: '爻', variants: [], strokes: 4, name: 'double x'),
|
||||
(
|
||||
number: 90,
|
||||
character: '爿',
|
||||
variants: [],
|
||||
strokes: 4,
|
||||
name: 'half tree trunk',
|
||||
),
|
||||
(number: 91, character: '片', variants: [], strokes: 4, name: 'slice'),
|
||||
(number: 92, character: '牙', variants: [], strokes: 4, name: 'fang'),
|
||||
(number: 93, character: '牛', variants: ['牜'], strokes: 4, name: 'cow'),
|
||||
(number: 94, character: '犬', variants: ['犭'], strokes: 4, name: 'dog'),
|
||||
(number: 95, character: '玄', variants: [], strokes: 5, name: 'profound'),
|
||||
(number: 96, character: '玉', variants: ['王'], strokes: 5, name: 'jade'),
|
||||
(number: 97, character: '瓜', variants: [], strokes: 5, name: 'melon'),
|
||||
(number: 98, character: '瓦', variants: [], strokes: 5, name: 'tile'),
|
||||
(number: 99, character: '甘', variants: [], strokes: 5, name: 'sweet'),
|
||||
(number: 100, character: '生', variants: [], strokes: 5, name: 'life'),
|
||||
(number: 101, character: '用', variants: [], strokes: 5, name: 'use'),
|
||||
(number: 102, character: '田', variants: [], strokes: 5, name: 'field'),
|
||||
(
|
||||
number: 103,
|
||||
character: '疋',
|
||||
variants: [],
|
||||
strokes: 5,
|
||||
name: 'bolt of cloth',
|
||||
),
|
||||
(number: 104, character: '疒', variants: [], strokes: 5, name: 'sickness'),
|
||||
(number: 105, character: '癶', variants: [], strokes: 5, name: 'dotted tent'),
|
||||
(number: 106, character: '白', variants: [], strokes: 5, name: 'white'),
|
||||
(number: 107, character: '皮', variants: [], strokes: 5, name: 'skin'),
|
||||
(number: 108, character: '皿', variants: [], strokes: 5, name: 'dish'),
|
||||
(number: 109, character: '目', variants: [], strokes: 5, name: 'eye'),
|
||||
(number: 110, character: '矛', variants: [], strokes: 5, name: 'spear'),
|
||||
(number: 111, character: '矢', variants: [], strokes: 5, name: 'arrow'),
|
||||
(number: 112, character: '石', variants: [], strokes: 5, name: 'stone'),
|
||||
(number: 113, character: '示', variants: ['礻'], strokes: 5, name: 'spirit'),
|
||||
(number: 114, character: '禸', variants: [], strokes: 5, name: 'track'),
|
||||
(number: 115, character: '禾', variants: [], strokes: 5, name: 'grain'),
|
||||
(number: 116, character: '穴', variants: [], strokes: 5, name: 'cave'),
|
||||
(number: 117, character: '立', variants: [], strokes: 5, name: 'stand'),
|
||||
(number: 118, character: '竹', variants: [], strokes: 6, name: 'bamboo'),
|
||||
(number: 119, character: '米', variants: [], strokes: 6, name: 'rice'),
|
||||
(number: 120, character: '糸', variants: [], strokes: 6, name: 'silk'),
|
||||
(number: 121, character: '缶', variants: [], strokes: 6, name: 'jar'),
|
||||
(number: 122, character: '网', variants: ['罒'], strokes: 6, name: 'net'),
|
||||
(number: 123, character: '羊', variants: [], strokes: 6, name: 'sheep'),
|
||||
(number: 124, character: '羽', variants: [], strokes: 6, name: 'feather'),
|
||||
(number: 125, character: '老', variants: ['耂'], strokes: 6, name: 'old'),
|
||||
(number: 126, character: '而', variants: [], strokes: 6, name: 'and'),
|
||||
(number: 127, character: '耒', variants: [], strokes: 6, name: 'plow'),
|
||||
(number: 128, character: '耳', variants: [], strokes: 6, name: 'ear'),
|
||||
(number: 129, character: '聿', variants: [], strokes: 6, name: 'brush'),
|
||||
(number: 130, character: '肉', variants: [], strokes: 6, name: 'meat'),
|
||||
(number: 131, character: '臣', variants: [], strokes: 6, name: 'minister'),
|
||||
(number: 132, character: '自', variants: [], strokes: 6, name: 'self'),
|
||||
(number: 133, character: '至', variants: [], strokes: 6, name: 'arrive'),
|
||||
(number: 134, character: '臼', variants: [], strokes: 6, name: 'mortar'),
|
||||
(number: 135, character: '舌', variants: [], strokes: 6, name: 'tongue'),
|
||||
(number: 136, character: '舛', variants: [], strokes: 6, name: 'oppose'),
|
||||
(number: 137, character: '舟', variants: [], strokes: 6, name: 'boat'),
|
||||
(number: 138, character: '艮', variants: [], strokes: 6, name: 'stopping'),
|
||||
(number: 139, character: '色', variants: [], strokes: 6, name: 'color'),
|
||||
(number: 140, character: '艸', variants: ['艹'], strokes: 6, name: 'grass'),
|
||||
(number: 141, character: '虍', variants: [], strokes: 6, name: 'tiger'),
|
||||
(number: 142, character: '虫', variants: [], strokes: 6, name: 'insect'),
|
||||
(number: 143, character: '血', variants: [], strokes: 6, name: 'blood'),
|
||||
(
|
||||
number: 144,
|
||||
character: '行',
|
||||
variants: [],
|
||||
strokes: 6,
|
||||
name: 'walk enclosure',
|
||||
),
|
||||
(number: 145, character: '衣', variants: ['衤'], strokes: 6, name: 'clothes'),
|
||||
(number: 146, character: '襾', variants: [], strokes: 6, name: 'west'),
|
||||
(number: 147, character: '見', variants: [], strokes: 7, name: 'see'),
|
||||
(number: 148, character: '角', variants: [], strokes: 7, name: 'horn'),
|
||||
(number: 149, character: '言', variants: [], strokes: 7, name: 'speech'),
|
||||
(number: 150, character: '谷', variants: [], strokes: 7, name: 'valley'),
|
||||
(number: 151, character: '豆', variants: [], strokes: 7, name: 'bean'),
|
||||
(number: 152, character: '豕', variants: [], strokes: 7, name: 'pig'),
|
||||
(number: 153, character: '豸', variants: [], strokes: 7, name: 'badger'),
|
||||
(number: 154, character: '貝', variants: [], strokes: 7, name: 'shell'),
|
||||
(number: 155, character: '赤', variants: [], strokes: 7, name: 'red'),
|
||||
(number: 156, character: '走', variants: [], strokes: 7, name: 'run'),
|
||||
(number: 157, character: '足', variants: [], strokes: 7, name: 'foot'),
|
||||
(number: 158, character: '身', variants: [], strokes: 7, name: 'body'),
|
||||
(number: 159, character: '車', variants: [], strokes: 7, name: 'cart'),
|
||||
(number: 160, character: '辛', variants: [], strokes: 7, name: 'bitter'),
|
||||
(number: 161, character: '辰', variants: [], strokes: 7, name: 'morning'),
|
||||
(number: 162, character: '辵', variants: ['辶'], strokes: 7, name: 'walk'),
|
||||
(number: 163, character: '邑', variants: ['阝'], strokes: 7, name: 'city'),
|
||||
(number: 164, character: '酉', variants: [], strokes: 7, name: 'wine'),
|
||||
(number: 165, character: '釆', variants: [], strokes: 7, name: 'distinguish'),
|
||||
(number: 166, character: '里', variants: [], strokes: 7, name: 'village'),
|
||||
(number: 167, character: '金', variants: [], strokes: 8, name: 'gold'),
|
||||
(number: 168, character: '長', variants: [], strokes: 8, name: 'long'),
|
||||
(number: 169, character: '門', variants: [], strokes: 8, name: 'gate'),
|
||||
(number: 170, character: '阜', variants: ['阝'], strokes: 8, name: 'mound'),
|
||||
(number: 171, character: '隶', variants: [], strokes: 8, name: 'slave'),
|
||||
(
|
||||
number: 172,
|
||||
character: '隹',
|
||||
variants: [],
|
||||
strokes: 8,
|
||||
name: 'short tailed bird',
|
||||
),
|
||||
(number: 173, character: '雨', variants: [], strokes: 8, name: 'rain'),
|
||||
(number: 174, character: '靑', variants: ['青'], strokes: 8, name: 'blue'),
|
||||
(number: 175, character: '非', variants: [], strokes: 8, name: 'wrong'),
|
||||
(number: 176, character: '面', variants: [], strokes: 9, name: 'face'),
|
||||
(number: 177, character: '革', variants: [], strokes: 9, name: 'leather'),
|
||||
(
|
||||
number: 178,
|
||||
character: '韋',
|
||||
variants: [],
|
||||
strokes: 9,
|
||||
name: 'tanned leather',
|
||||
),
|
||||
(number: 179, character: '韭', variants: [], strokes: 9, name: 'leek'),
|
||||
(number: 180, character: '音', variants: [], strokes: 9, name: 'sound'),
|
||||
(number: 181, character: '頁', variants: [], strokes: 9, name: 'leaf'),
|
||||
(number: 182, character: '風', variants: [], strokes: 9, name: 'wind'),
|
||||
(number: 183, character: '飛', variants: [], strokes: 9, name: 'fly'),
|
||||
(number: 184, character: '食', variants: ['飠'], strokes: 9, name: 'eat'),
|
||||
(number: 185, character: '首', variants: [], strokes: 9, name: 'head'),
|
||||
(number: 186, character: '香', variants: [], strokes: 9, name: 'fragrant'),
|
||||
(number: 187, character: '馬', variants: [], strokes: 10, name: 'horse'),
|
||||
(number: 188, character: '骨', variants: [], strokes: 10, name: 'bone'),
|
||||
(number: 189, character: '高', variants: [], strokes: 10, name: 'tall'),
|
||||
(number: 190, character: '髟', variants: [], strokes: 10, name: 'hair'),
|
||||
(number: 191, character: '鬥', variants: [], strokes: 10, name: 'fight'),
|
||||
(
|
||||
number: 192,
|
||||
character: '鬯',
|
||||
variants: [],
|
||||
strokes: 10,
|
||||
name: 'sacrificial wine',
|
||||
),
|
||||
(number: 193, character: '鬲', variants: [], strokes: 10, name: 'cauldron'),
|
||||
(number: 194, character: '鬼', variants: [], strokes: 10, name: 'ghost'),
|
||||
(number: 195, character: '魚', variants: [], strokes: 11, name: 'fish'),
|
||||
(number: 196, character: '鳥', variants: [], strokes: 11, name: 'bird'),
|
||||
(number: 197, character: '鹵', variants: [], strokes: 11, name: 'salt'),
|
||||
(number: 198, character: '鹿', variants: [], strokes: 11, name: 'deer'),
|
||||
(number: 199, character: '麥', variants: ['麦'], strokes: 11, name: 'wheat'),
|
||||
(number: 200, character: '麻', variants: [], strokes: 11, name: 'hemp'),
|
||||
(number: 201, character: '黃', variants: ['黄'], strokes: 12, name: 'yellow'),
|
||||
(number: 202, character: '黍', variants: [], strokes: 12, name: 'millet'),
|
||||
(number: 203, character: '黑', variants: ['黒'], strokes: 12, name: 'black'),
|
||||
(number: 204, character: '黹', variants: [], strokes: 12, name: 'embroidery'),
|
||||
(number: 205, character: '黽', variants: [], strokes: 13, name: 'frog'),
|
||||
(number: 206, character: '鼎', variants: [], strokes: 13, name: 'tripod'),
|
||||
(number: 207, character: '鼓', variants: [], strokes: 13, name: 'drum'),
|
||||
(number: 208, character: '鼠', variants: [], strokes: 13, name: 'rat'),
|
||||
(number: 209, character: '鼻', variants: [], strokes: 14, name: 'nose'),
|
||||
(number: 210, character: '齊', variants: ['斉'], strokes: 14, name: 'even'),
|
||||
(number: 211, character: '齒', variants: ['歯'], strokes: 15, name: 'tooth'),
|
||||
(number: 212, character: '龍', variants: ['竜'], strokes: 16, name: 'dragon'),
|
||||
(number: 213, character: '龜', variants: ['亀'], strokes: 16, name: 'turtle'),
|
||||
(number: 214, character: '龠', variants: [], strokes: 17, name: 'flute'),
|
||||
];
|
||||
|
||||
/// The label a dictionary of characters files a kanji under the radical
|
||||
/// [number] by (KANJIDIC2's classical radical).
|
||||
String radicalLabel(int number) => 'radical:$number';
|
||||
|
|
@ -338,6 +338,96 @@ void main() {
|
|||
expect(find.text('Verb'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('Radicals', () {
|
||||
final hito = character(
|
||||
'人',
|
||||
meanings: ['person'],
|
||||
labels: ['jlpt:n5', 'grade:1', 'radical:9'],
|
||||
);
|
||||
final yasumu = character(
|
||||
'休',
|
||||
meanings: ['rest'],
|
||||
labels: ['jlpt:n5', 'grade:1', 'radical:9'],
|
||||
);
|
||||
|
||||
/// An instance whose KANJIDIC2 files 人 and 休 under the radical 人.
|
||||
FakeDictionaryGateway withRadicals() => FakeDictionaryGateway(
|
||||
available: [jmdict, kanjidic],
|
||||
words: [taberu, hito, yasumu, shoku],
|
||||
);
|
||||
|
||||
Future<void> openRadicals(
|
||||
WidgetTester tester,
|
||||
FakeDictionaryGateway dictionaries,
|
||||
) async {
|
||||
await show(
|
||||
tester,
|
||||
dictionaries: dictionaries,
|
||||
location: RoutePaths.dictionary('japanese'),
|
||||
);
|
||||
await tester.tap(find.text('Radicals'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
testWidgets('the radicals by their strokes; one lists the kanji written '
|
||||
'with it, to add, and leads back to them all', (tester) async {
|
||||
await openRadicals(tester, withRadicals());
|
||||
|
||||
expect(find.text('1 stroke'), findsOneWidget);
|
||||
expect(find.text('2 strokes'), findsOneWidget);
|
||||
await tester.tap(find.text('9'));
|
||||
await settle(tester);
|
||||
|
||||
expect(find.text('man'), findsOneWidget);
|
||||
expect(find.text('Radical 9 · 2 strokes · 2 kanji'), findsOneWidget);
|
||||
expect(find.text('食'), findsNothing);
|
||||
await tester.tap(find.text('Add').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Added “人”.'), findsOneWidget);
|
||||
expect(
|
||||
(await cards.cardsOf('japanese')).single.content.entry,
|
||||
(source: 'kanjidic', id: '人'),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('All radicals'));
|
||||
await settle(tester);
|
||||
expect(find.text('2 strokes'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("a radical's kanji the instance can't give now are asked "
|
||||
'for again', (tester) async {
|
||||
final dictionaries = withRadicals();
|
||||
await openRadicals(tester, dictionaries);
|
||||
dictionaries.fail = Exception('offline');
|
||||
await tester.tap(find.text('9'));
|
||||
await settle(tester);
|
||||
|
||||
expect(
|
||||
find.text("The dictionary can't be reached right now."),
|
||||
findsOneWidget,
|
||||
);
|
||||
dictionaries.fail = null;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await settle(tester);
|
||||
expect(find.text('Radical 9 · 2 strokes · 2 kanji'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a radical KANJIDIC2 files nothing under on this instance '
|
||||
'says so', (tester) async {
|
||||
await openRadicals(tester, withKanji());
|
||||
await tester.tap(find.text('9'));
|
||||
await settle(tester);
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
"The instance's dictionary has no kanji under this radical "
|
||||
'yet.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("About names whose work the instance's dictionaries and the "
|
||||
"app's own data are", (
|
||||
tester,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/kanji/model/radical.dart';
|
||||
|
||||
void main() {
|
||||
test('the 214 Kangxi radicals in their order, by their strokes from 1 '
|
||||
'to 17', () {
|
||||
expect(
|
||||
[for (final radical in radicals) radical.number],
|
||||
[
|
||||
for (var number = 1; number <= 214; number++) number,
|
||||
],
|
||||
);
|
||||
final strokes = [for (final radical in radicals) radical.strokes];
|
||||
expect(strokes, orderedEquals([...strokes]..sort()));
|
||||
expect((strokes.first, strokes.last), (1, 17));
|
||||
// Const, as the table is: a record compares its lists by identity.
|
||||
expect(radicals[84], const (
|
||||
number: 85,
|
||||
character: '水',
|
||||
variants: ['氵'],
|
||||
strokes: 4,
|
||||
name: 'water',
|
||||
));
|
||||
});
|
||||
|
||||
test('each is one character, as is every form it takes in kanji', () {
|
||||
for (final radical in radicals) {
|
||||
for (final form in [radical.character, ...radical.variants]) {
|
||||
expect(form.runes, hasLength(1), reason: '${radical.number}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a radical's label is the dictionary's", () {
|
||||
expect(radicalLabel(85), 'radical:85');
|
||||
});
|
||||
}
|
||||
|
|
@ -160,8 +160,12 @@ class FakeDictionaryGateway({
|
|||
}
|
||||
|
||||
@override
|
||||
Future<int> countLabelled(Dictionary dictionary, String label) async =>
|
||||
_of(dictionary).where((word) => word.labels.contains(label)).length;
|
||||
Future<int> countLabelled(Dictionary dictionary, String label) async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return _of(dictionary).where((word) => word.labels.contains(label)).length;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DictionaryItem>> labelled(
|
||||
|
|
@ -169,10 +173,14 @@ class FakeDictionaryGateway({
|
|||
String label, {
|
||||
required int offset,
|
||||
required int limit,
|
||||
}) async =>
|
||||
_of(dictionary)
|
||||
}) async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return _of(dictionary)
|
||||
.where((word) => word.labels.contains(label))
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
halkyon_learn_server/.gitignore
vendored
1
halkyon_learn_server/.gitignore
vendored
|
|
@ -24,5 +24,6 @@ config/firebase_service_account_key.json
|
|||
# Dictionary files: made by halkyon_learn_curator, not kept in git.
|
||||
/dictionaries/*.jsonl
|
||||
/dictionaries/*.jsonl.gz
|
||||
/dictionaries/.conversion
|
||||
# The published studysets' repositories of a development server
|
||||
/repositories/
|
||||
|
|
|
|||
Loading…
Reference in a new issue