feat(app): a character card for kanji and kana
One card type for both (card schema 7): the character on the front, its meanings and a kanji's on and kun readings, each merging on its own. A kana's romaji is its meaning, so kana are studied, and later written, exactly as kanji are. The composer offers it beside the other kinds, each kind of reading on one line (ショク、ジキ); studying shows the character large and, with the answer, its meanings and readings (the other way round when asked from the back); the card browser filters by it and finds a kun reading without its okurigana dot; the Markdown export writes its meanings and readings. 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
a6699b30cb
commit
2fc4205010
21 changed files with 765 additions and 18 deletions
|
|
@ -147,7 +147,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **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, 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.
|
||||||
- **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`).
|
- **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`.
|
- **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).
|
- **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).
|
||||||
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3: `anki`, or `text` for flashcards pasted or read from CSV/TSV); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
|
- **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3: `anki`, or `text` for flashcards pasted or read from CSV/TSV); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`tool/anki/make_fixtures.py`).
|
||||||
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
- **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one.
|
||||||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||||
|
|
|
||||||
|
|
@ -567,16 +567,19 @@
|
||||||
"composerKindVocabulary": "Vokabel",
|
"composerKindVocabulary": "Vokabel",
|
||||||
"composerKindMatching": "Zuordnung",
|
"composerKindMatching": "Zuordnung",
|
||||||
"composerKindMap": "Landkarte",
|
"composerKindMap": "Landkarte",
|
||||||
|
"composerKindCharacter": "Schriftzeichen",
|
||||||
"composerLabelFront": "Vorderseite",
|
"composerLabelFront": "Vorderseite",
|
||||||
"composerLabelQuestion": "Frage",
|
"composerLabelQuestion": "Frage",
|
||||||
"composerLabelExpression": "Ausdruck",
|
"composerLabelExpression": "Ausdruck",
|
||||||
"composerLabelPrompt": "Aufgabe",
|
"composerLabelPrompt": "Aufgabe",
|
||||||
|
"composerLabelCharacter": "Schriftzeichen",
|
||||||
"composerLabelBack": "Rückseite",
|
"composerLabelBack": "Rückseite",
|
||||||
"composerLabelAnswers": "Antworten",
|
"composerLabelAnswers": "Antworten",
|
||||||
"composerLabelAnswer": "Antwort",
|
"composerLabelAnswer": "Antwort",
|
||||||
"composerPlaceholderFront": "Frage hinzufügen",
|
"composerPlaceholderFront": "Frage hinzufügen",
|
||||||
"composerPlaceholderExpression": "Ausdruck hinzufügen",
|
"composerPlaceholderExpression": "Ausdruck hinzufügen",
|
||||||
"composerPlaceholderPrompt": "Aufgabe hinzufügen",
|
"composerPlaceholderPrompt": "Aufgabe hinzufügen",
|
||||||
|
"composerPlaceholderCharacter": "Kanji oder Kana hinzufügen",
|
||||||
"composerAddAnswer": "Antwort hinzufügen",
|
"composerAddAnswer": "Antwort hinzufügen",
|
||||||
"composerCorrectAnswer": "Richtige Antwort",
|
"composerCorrectAnswer": "Richtige Antwort",
|
||||||
"composerWrongAnswer": "Falsche Antwort",
|
"composerWrongAnswer": "Falsche Antwort",
|
||||||
|
|
@ -623,6 +626,13 @@
|
||||||
"vocabularyRemoveGloss": "Übersetzung entfernen",
|
"vocabularyRemoveGloss": "Übersetzung entfernen",
|
||||||
"vocabularyAddSense": "Wortbedeutung hinzufügen",
|
"vocabularyAddSense": "Wortbedeutung hinzufügen",
|
||||||
"vocabularyRemoveSense": "Wortbedeutung entfernen",
|
"vocabularyRemoveSense": "Wortbedeutung entfernen",
|
||||||
|
"characterPrimaryMeaningHint": "Hauptbedeutung, bei Kana die Umschrift",
|
||||||
|
"characterOnReadings": "On-Lesungen",
|
||||||
|
"characterOnReadingsHint": "Aus dem Chinesischen, in Katakana: ショク、ジキ",
|
||||||
|
"characterKunReadings": "Kun-Lesungen",
|
||||||
|
"characterKunReadingsHint": "Japanisch, in Hiragana, Okurigana nach einem Punkt: た.べる",
|
||||||
|
"characterOn": "On",
|
||||||
|
"characterKun": "Kun",
|
||||||
"matchingCategories": "Kategorien",
|
"matchingCategories": "Kategorien",
|
||||||
"matchingCategoriesHint": "Die Ablagekarten, auf die Lernende Elemente ziehen. Jede Kategorie braucht mindestens ein Element, daher kann eine Karte nicht mehr Kategorien als Elemente haben.",
|
"matchingCategoriesHint": "Die Ablagekarten, auf die Lernende Elemente ziehen. Jede Kategorie braucht mindestens ein Element, daher kann eine Karte nicht mehr Kategorien als Elemente haben.",
|
||||||
"matchingCategory": "Kategorie {number}",
|
"matchingCategory": "Kategorie {number}",
|
||||||
|
|
@ -1213,6 +1223,7 @@
|
||||||
"cardBrowserTypeVocabulary": "Nur Wörter",
|
"cardBrowserTypeVocabulary": "Nur Wörter",
|
||||||
"cardBrowserTypeMatching": "Nur Zuordnungen",
|
"cardBrowserTypeMatching": "Nur Zuordnungen",
|
||||||
"cardBrowserTypeMap": "Nur Karten",
|
"cardBrowserTypeMap": "Nur Karten",
|
||||||
|
"cardBrowserTypeCharacter": "Nur Schriftzeichen",
|
||||||
"cardBrowserFilterTag": "Tag",
|
"cardBrowserFilterTag": "Tag",
|
||||||
"cardBrowserAllTags": "Alle Tags",
|
"cardBrowserAllTags": "Alle Tags",
|
||||||
"cardBrowserFilterStatus": "Stand",
|
"cardBrowserFilterStatus": "Stand",
|
||||||
|
|
|
||||||
|
|
@ -625,16 +625,19 @@
|
||||||
"composerKindVocabulary": "Vocabulary",
|
"composerKindVocabulary": "Vocabulary",
|
||||||
"composerKindMatching": "Matching",
|
"composerKindMatching": "Matching",
|
||||||
"composerKindMap": "Map",
|
"composerKindMap": "Map",
|
||||||
|
"composerKindCharacter": "Character",
|
||||||
"composerLabelFront": "Front",
|
"composerLabelFront": "Front",
|
||||||
"composerLabelQuestion": "Question",
|
"composerLabelQuestion": "Question",
|
||||||
"composerLabelExpression": "Expression",
|
"composerLabelExpression": "Expression",
|
||||||
"composerLabelPrompt": "Prompt",
|
"composerLabelPrompt": "Prompt",
|
||||||
|
"composerLabelCharacter": "Character",
|
||||||
"composerLabelBack": "Back",
|
"composerLabelBack": "Back",
|
||||||
"composerLabelAnswers": "Answers",
|
"composerLabelAnswers": "Answers",
|
||||||
"composerLabelAnswer": "Answer",
|
"composerLabelAnswer": "Answer",
|
||||||
"composerPlaceholderFront": "Add question",
|
"composerPlaceholderFront": "Add question",
|
||||||
"composerPlaceholderExpression": "Add expression",
|
"composerPlaceholderExpression": "Add expression",
|
||||||
"composerPlaceholderPrompt": "Add prompt",
|
"composerPlaceholderPrompt": "Add prompt",
|
||||||
|
"composerPlaceholderCharacter": "Add a kanji or kana",
|
||||||
"composerAddAnswer": "Add answer",
|
"composerAddAnswer": "Add answer",
|
||||||
"composerCorrectAnswer": "Correct answer",
|
"composerCorrectAnswer": "Correct answer",
|
||||||
"composerWrongAnswer": "Wrong answer",
|
"composerWrongAnswer": "Wrong answer",
|
||||||
|
|
@ -685,6 +688,13 @@
|
||||||
"vocabularyRemoveGloss": "Remove translation",
|
"vocabularyRemoveGloss": "Remove translation",
|
||||||
"vocabularyAddSense": "Add sense",
|
"vocabularyAddSense": "Add sense",
|
||||||
"vocabularyRemoveSense": "Remove sense",
|
"vocabularyRemoveSense": "Remove sense",
|
||||||
|
"characterPrimaryMeaningHint": "Primary meaning, or a kana's romaji",
|
||||||
|
"characterOnReadings": "On readings",
|
||||||
|
"characterOnReadingsHint": "From Chinese, in katakana: ショク、ジキ",
|
||||||
|
"characterKunReadings": "Kun readings",
|
||||||
|
"characterKunReadingsHint": "Japanese, in hiragana, okurigana after a dot: た.べる",
|
||||||
|
"characterOn": "On",
|
||||||
|
"characterKun": "Kun",
|
||||||
"matchingCategories": "Categories",
|
"matchingCategories": "Categories",
|
||||||
"matchingCategoriesHint": "The drop-zone cards learners drag items onto. Each category needs at least one item, so a card can't have more categories than items.",
|
"matchingCategoriesHint": "The drop-zone cards learners drag items onto. Each category needs at least one item, so a card can't have more categories than items.",
|
||||||
"matchingCategory": "Category {number}",
|
"matchingCategory": "Category {number}",
|
||||||
|
|
@ -1427,6 +1437,7 @@
|
||||||
"cardBrowserTypeVocabulary": "Words only",
|
"cardBrowserTypeVocabulary": "Words only",
|
||||||
"cardBrowserTypeMatching": "Matching only",
|
"cardBrowserTypeMatching": "Matching only",
|
||||||
"cardBrowserTypeMap": "Maps only",
|
"cardBrowserTypeMap": "Maps only",
|
||||||
|
"cardBrowserTypeCharacter": "Characters only",
|
||||||
"cardBrowserFilterTag": "Tag",
|
"cardBrowserFilterTag": "Tag",
|
||||||
"cardBrowserAllTags": "All tags",
|
"cardBrowserAllTags": "All tags",
|
||||||
"cardBrowserFilterStatus": "Status",
|
"cardBrowserFilterStatus": "Status",
|
||||||
|
|
|
||||||
|
|
@ -3418,6 +3418,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Map'**
|
/// **'Map'**
|
||||||
String get composerKindMap;
|
String get composerKindMap;
|
||||||
|
|
||||||
|
/// No description provided for @composerKindCharacter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Character'**
|
||||||
|
String get composerKindCharacter;
|
||||||
|
|
||||||
/// No description provided for @composerLabelFront.
|
/// No description provided for @composerLabelFront.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3442,6 +3448,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Prompt'**
|
/// **'Prompt'**
|
||||||
String get composerLabelPrompt;
|
String get composerLabelPrompt;
|
||||||
|
|
||||||
|
/// No description provided for @composerLabelCharacter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Character'**
|
||||||
|
String get composerLabelCharacter;
|
||||||
|
|
||||||
/// No description provided for @composerLabelBack.
|
/// No description provided for @composerLabelBack.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3478,6 +3490,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Add prompt'**
|
/// **'Add prompt'**
|
||||||
String get composerPlaceholderPrompt;
|
String get composerPlaceholderPrompt;
|
||||||
|
|
||||||
|
/// No description provided for @composerPlaceholderCharacter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add a kanji or kana'**
|
||||||
|
String get composerPlaceholderCharacter;
|
||||||
|
|
||||||
/// No description provided for @composerAddAnswer.
|
/// No description provided for @composerAddAnswer.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3754,6 +3772,48 @@ abstract class AppLocalizations {
|
||||||
/// **'Remove sense'**
|
/// **'Remove sense'**
|
||||||
String get vocabularyRemoveSense;
|
String get vocabularyRemoveSense;
|
||||||
|
|
||||||
|
/// No description provided for @characterPrimaryMeaningHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Primary meaning, or a kana\'s romaji'**
|
||||||
|
String get characterPrimaryMeaningHint;
|
||||||
|
|
||||||
|
/// No description provided for @characterOnReadings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'On readings'**
|
||||||
|
String get characterOnReadings;
|
||||||
|
|
||||||
|
/// No description provided for @characterOnReadingsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'From Chinese, in katakana: ショク、ジキ'**
|
||||||
|
String get characterOnReadingsHint;
|
||||||
|
|
||||||
|
/// No description provided for @characterKunReadings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Kun readings'**
|
||||||
|
String get characterKunReadings;
|
||||||
|
|
||||||
|
/// No description provided for @characterKunReadingsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Japanese, in hiragana, okurigana after a dot: た.べる'**
|
||||||
|
String get characterKunReadingsHint;
|
||||||
|
|
||||||
|
/// No description provided for @characterOn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'On'**
|
||||||
|
String get characterOn;
|
||||||
|
|
||||||
|
/// No description provided for @characterKun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Kun'**
|
||||||
|
String get characterKun;
|
||||||
|
|
||||||
/// No description provided for @matchingCategories.
|
/// No description provided for @matchingCategories.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -7257,6 +7317,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Maps only'**
|
/// **'Maps only'**
|
||||||
String get cardBrowserTypeMap;
|
String get cardBrowserTypeMap;
|
||||||
|
|
||||||
|
/// No description provided for @cardBrowserTypeCharacter.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Characters only'**
|
||||||
|
String get cardBrowserTypeCharacter;
|
||||||
|
|
||||||
/// No description provided for @cardBrowserFilterTag.
|
/// No description provided for @cardBrowserFilterTag.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -2029,6 +2029,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerKindMap => 'Landkarte';
|
String get composerKindMap => 'Landkarte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerKindCharacter => 'Schriftzeichen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerLabelFront => 'Vorderseite';
|
String get composerLabelFront => 'Vorderseite';
|
||||||
|
|
||||||
|
|
@ -2041,6 +2044,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerLabelPrompt => 'Aufgabe';
|
String get composerLabelPrompt => 'Aufgabe';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelCharacter => 'Schriftzeichen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerLabelBack => 'Rückseite';
|
String get composerLabelBack => 'Rückseite';
|
||||||
|
|
||||||
|
|
@ -2059,6 +2065,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerPlaceholderPrompt => 'Aufgabe hinzufügen';
|
String get composerPlaceholderPrompt => 'Aufgabe hinzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerPlaceholderCharacter => 'Kanji oder Kana hinzufügen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerAddAnswer => 'Antwort hinzufügen';
|
String get composerAddAnswer => 'Antwort hinzufügen';
|
||||||
|
|
||||||
|
|
@ -2221,6 +2230,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get vocabularyRemoveSense => 'Wortbedeutung entfernen';
|
String get vocabularyRemoveSense => 'Wortbedeutung entfernen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterPrimaryMeaningHint =>
|
||||||
|
'Hauptbedeutung, bei Kana die Umschrift';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterOnReadings => 'On-Lesungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterOnReadingsHint =>
|
||||||
|
'Aus dem Chinesischen, in Katakana: ショク、ジキ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterKunReadings => 'Kun-Lesungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterKunReadingsHint =>
|
||||||
|
'Japanisch, in Hiragana, Okurigana nach einem Punkt: た.べる';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterOn => 'On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterKun => 'Kun';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get matchingCategories => 'Kategorien';
|
String get matchingCategories => 'Kategorien';
|
||||||
|
|
||||||
|
|
@ -4430,6 +4463,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cardBrowserTypeMap => 'Nur Karten';
|
String get cardBrowserTypeMap => 'Nur Karten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardBrowserTypeCharacter => 'Nur Schriftzeichen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cardBrowserFilterTag => 'Tag';
|
String get cardBrowserFilterTag => 'Tag';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2016,6 +2016,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerKindMap => 'Map';
|
String get composerKindMap => 'Map';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerKindCharacter => 'Character';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerLabelFront => 'Front';
|
String get composerLabelFront => 'Front';
|
||||||
|
|
||||||
|
|
@ -2028,6 +2031,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerLabelPrompt => 'Prompt';
|
String get composerLabelPrompt => 'Prompt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerLabelCharacter => 'Character';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerLabelBack => 'Back';
|
String get composerLabelBack => 'Back';
|
||||||
|
|
||||||
|
|
@ -2046,6 +2052,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get composerPlaceholderPrompt => 'Add prompt';
|
String get composerPlaceholderPrompt => 'Add prompt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get composerPlaceholderCharacter => 'Add a kanji or kana';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get composerAddAnswer => 'Add answer';
|
String get composerAddAnswer => 'Add answer';
|
||||||
|
|
||||||
|
|
@ -2208,6 +2217,29 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get vocabularyRemoveSense => 'Remove sense';
|
String get vocabularyRemoveSense => 'Remove sense';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterPrimaryMeaningHint =>
|
||||||
|
'Primary meaning, or a kana\'s romaji';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterOnReadings => 'On readings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterOnReadingsHint => 'From Chinese, in katakana: ショク、ジキ';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterKunReadings => 'Kun readings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterKunReadingsHint =>
|
||||||
|
'Japanese, in hiragana, okurigana after a dot: た.べる';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterOn => 'On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get characterKun => 'Kun';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get matchingCategories => 'Categories';
|
String get matchingCategories => 'Categories';
|
||||||
|
|
||||||
|
|
@ -4397,6 +4429,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cardBrowserTypeMap => 'Maps only';
|
String get cardBrowserTypeMap => 'Maps only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardBrowserTypeCharacter => 'Characters only';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cardBrowserFilterTag => 'Tag';
|
String get cardBrowserFilterTag => 'Tag';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
// `senses` ([{glosses, label, pos, misc, field, dialect}], the codes as
|
// `senses` ([{glosses, label, pos, misc, field, dialect}], the codes as
|
||||||
// dictionaries write them). Schema 6 added the dictionary entry a word was
|
// dictionaries write them). Schema 6 added the dictionary entry a word was
|
||||||
// made from (`dictionary`: {source, id}), left out for a word of its own.
|
// made from (`dictionary`: {source, id}), left out for a word of its own.
|
||||||
|
// Schema 7 added the character card (type `character`) and a kanji's
|
||||||
|
// readings, `on` and `kun`, each left out while empty.
|
||||||
|
|
||||||
/// The schema this app writes.
|
/// The schema this app writes.
|
||||||
const cardSchemaVersion = 6;
|
const cardSchemaVersion = 7;
|
||||||
|
|
||||||
/// Encodes a card's content into a revision's payload; [origin] names where
|
/// Encodes a card's content into a revision's payload; [origin] names where
|
||||||
/// an imported card came from, in its first revision.
|
/// an imported card came from, in its first revision.
|
||||||
|
|
@ -43,6 +45,8 @@ ByteData encodeCardPayload(CardContent content, {String? origin}) =>
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
if (content.reading.isNotEmpty) 'reading': content.reading,
|
if (content.reading.isNotEmpty) 'reading': content.reading,
|
||||||
|
if (content.onReadings.isNotEmpty) 'on': content.onReadings,
|
||||||
|
if (content.kunReadings.isNotEmpty) 'kun': content.kunReadings,
|
||||||
if (content.meanings.isNotEmpty) 'meanings': content.meanings,
|
if (content.meanings.isNotEmpty) 'meanings': content.meanings,
|
||||||
if (content.matching.targets.isNotEmpty ||
|
if (content.matching.targets.isNotEmpty ||
|
||||||
content.matching.items.isNotEmpty)
|
content.matching.items.isNotEmpty)
|
||||||
|
|
@ -112,6 +116,8 @@ CardContent decodeCardPayload(ByteData payload) {
|
||||||
answers: _answers(json['answers']),
|
answers: _answers(json['answers']),
|
||||||
reading: _optional<String>(json, 'reading') ?? '',
|
reading: _optional<String>(json, 'reading') ?? '',
|
||||||
meanings: _strings(json['meanings'], 'meanings'),
|
meanings: _strings(json['meanings'], 'meanings'),
|
||||||
|
onReadings: _strings(json['on'], 'on readings'),
|
||||||
|
kunReadings: _strings(json['kun'], 'kun readings'),
|
||||||
matching: _matching(json['matching']),
|
matching: _matching(json['matching']),
|
||||||
map: _optional<String>(json, 'map'),
|
map: _optional<String>(json, 'map'),
|
||||||
source: pdfPageRefFromJson(json['source']),
|
source: pdfPageRefFromJson(json['source']),
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,10 @@ enum CardBrowserKind() {
|
||||||
matching,
|
matching,
|
||||||
|
|
||||||
/// A region to point at on a map.
|
/// A region to point at on a map.
|
||||||
map;
|
map,
|
||||||
|
|
||||||
|
/// A kanji or a kana.
|
||||||
|
character;
|
||||||
|
|
||||||
/// The kind of [content].
|
/// The kind of [content].
|
||||||
static CardBrowserKind of(CardContent content) => switch (content.type) {
|
static CardBrowserKind of(CardContent content) => switch (content.type) {
|
||||||
|
|
@ -64,6 +67,7 @@ enum CardBrowserKind() {
|
||||||
CardType.multipleChoice => multipleChoice,
|
CardType.multipleChoice => multipleChoice,
|
||||||
CardType.vocabulary => vocabulary,
|
CardType.vocabulary => vocabulary,
|
||||||
CardType.matching => matching,
|
CardType.matching => matching,
|
||||||
|
CardType.character => character,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,9 +99,10 @@ typedef BrowsedCard = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
/// What a search looks through in [card]: its front and back, a word's
|
/// What a search looks through in [card]: its front and back, a word's
|
||||||
/// reading, meanings and senses, a choice's answers, its hints and
|
/// reading, meanings and senses, a kanji's readings, a choice's answers,
|
||||||
/// explanation, and the names of its [tags]; folded as [searchFolded]
|
/// its hints and explanation, and the names of its [tags]; folded as
|
||||||
/// does.
|
/// [searchFolded] does (a kun reading's okurigana dot left out, so
|
||||||
|
/// `たべる` finds た.べる).
|
||||||
String searchTextOf(Card card, List<Tag> tags) {
|
String searchTextOf(Card card, List<Tag> tags) {
|
||||||
final content = card.content;
|
final content = card.content;
|
||||||
return searchFolded(
|
return searchFolded(
|
||||||
|
|
@ -106,6 +111,8 @@ String searchTextOf(Card card, List<Tag> tags) {
|
||||||
content.back.plainText,
|
content.back.plainText,
|
||||||
content.reading,
|
content.reading,
|
||||||
...content.meanings,
|
...content.meanings,
|
||||||
|
...content.onReadings,
|
||||||
|
for (final reading in content.kunReadings) reading.replaceAll('.', ''),
|
||||||
for (final sense in content.senses) ...sense.glosses,
|
for (final sense in content.senses) ...sense.glosses,
|
||||||
for (final answer in content.answers) answer.content.plainText,
|
for (final answer in content.answers) answer.content.plainText,
|
||||||
for (final hint in content.hints) hint.plainText,
|
for (final hint in content.hints) hint.plainText,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ enum CardType() {
|
||||||
|
|
||||||
/// Items to sort into targets, each into exactly one.
|
/// Items to sort into targets, each into exactly one.
|
||||||
matching,
|
matching,
|
||||||
|
|
||||||
|
/// A character: a kanji with its meanings and its on and kun readings,
|
||||||
|
/// or a kana with its romaji as its meaning.
|
||||||
|
character,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parts of a card, which merge separately when devices changed a card
|
/// The parts of a card, which merge separately when devices changed a card
|
||||||
|
|
@ -65,6 +69,12 @@ enum CardField() {
|
||||||
|
|
||||||
/// The dictionary entry a word was made from.
|
/// The dictionary entry a word was made from.
|
||||||
entry,
|
entry,
|
||||||
|
|
||||||
|
/// A kanji's on readings, from Chinese.
|
||||||
|
onReadings,
|
||||||
|
|
||||||
|
/// A kanji's kun readings, Japanese.
|
||||||
|
kunReadings,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a report says is wrong with a card.
|
/// What a report says is wrong with a card.
|
||||||
|
|
@ -230,6 +240,14 @@ final class const CardContent({
|
||||||
/// The dictionary entry it was made from; kept whatever its type, as the
|
/// The dictionary entry it was made from; kept whatever its type, as the
|
||||||
/// page of a PDF is.
|
/// page of a PDF is.
|
||||||
final DictionaryRef? entry,
|
final DictionaryRef? entry,
|
||||||
|
|
||||||
|
/// A kanji's on readings, in katakana as dictionaries write them
|
||||||
|
/// (`ショク`).
|
||||||
|
final List<String> onReadings = const [],
|
||||||
|
|
||||||
|
/// A kanji's kun readings, in hiragana, the okurigana after a dot as
|
||||||
|
/// dictionaries write them (`た.べる`).
|
||||||
|
final List<String> kunReadings = const [],
|
||||||
}) with Equatable implements MergeableContent<CardField, CardContent> {
|
}) with Equatable implements MergeableContent<CardField, CardContent> {
|
||||||
/// A front/back card with nothing on either side.
|
/// A front/back card with nothing on either side.
|
||||||
factory empty() =>
|
factory empty() =>
|
||||||
|
|
@ -254,6 +272,8 @@ final class const CardContent({
|
||||||
CardField.report => report,
|
CardField.report => report,
|
||||||
CardField.senses => senses,
|
CardField.senses => senses,
|
||||||
CardField.entry => entry,
|
CardField.entry => entry,
|
||||||
|
CardField.onReadings => onReadings,
|
||||||
|
CardField.kunReadings => kunReadings,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -276,6 +296,12 @@ final class const CardContent({
|
||||||
? value! as List<VocabularySense>
|
? value! as List<VocabularySense>
|
||||||
: senses,
|
: senses,
|
||||||
entry: field == CardField.entry ? value as DictionaryRef? : entry,
|
entry: field == CardField.entry ? value as DictionaryRef? : entry,
|
||||||
|
onReadings: field == CardField.onReadings
|
||||||
|
? value! as List<String>
|
||||||
|
: onReadings,
|
||||||
|
kunReadings: field == CardField.kunReadings
|
||||||
|
? value! as List<String>
|
||||||
|
: kunReadings,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// This content with some fields replaced; [withField] clears the map, the
|
/// This content with some fields replaced; [withField] clears the map, the
|
||||||
|
|
@ -293,6 +319,8 @@ final class const CardContent({
|
||||||
List<RichContent>? hints,
|
List<RichContent>? hints,
|
||||||
List<VocabularySense>? senses,
|
List<VocabularySense>? senses,
|
||||||
DictionaryRef? entry,
|
DictionaryRef? entry,
|
||||||
|
List<String>? onReadings,
|
||||||
|
List<String>? kunReadings,
|
||||||
}) => CardContent(
|
}) => CardContent(
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
front: front ?? this.front,
|
front: front ?? this.front,
|
||||||
|
|
@ -308,6 +336,8 @@ final class const CardContent({
|
||||||
report: report,
|
report: report,
|
||||||
senses: senses ?? this.senses,
|
senses: senses ?? this.senses,
|
||||||
entry: entry ?? this.entry,
|
entry: entry ?? this.entry,
|
||||||
|
onReadings: onReadings ?? this.onReadings,
|
||||||
|
kunReadings: kunReadings ?? this.kunReadings,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The fields [type] uses; the others stay empty. The page it was made
|
/// The fields [type] uses; the others stay empty. The page it was made
|
||||||
|
|
@ -328,6 +358,12 @@ final class const CardContent({
|
||||||
CardField.senses,
|
CardField.senses,
|
||||||
},
|
},
|
||||||
CardType.matching => const {CardField.front, CardField.matching},
|
CardType.matching => const {CardField.front, CardField.matching},
|
||||||
|
CardType.character => const {
|
||||||
|
CardField.front,
|
||||||
|
CardField.meanings,
|
||||||
|
CardField.onReadings,
|
||||||
|
CardField.kunReadings,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// This content with only the fields of its [type]; the others empty, as
|
/// This content with only the fields of its [type]; the others empty, as
|
||||||
|
|
@ -393,6 +429,13 @@ final class const CardContent({
|
||||||
_blank(front) &&
|
_blank(front) &&
|
||||||
matching.targets.every((target) => target.trim().isEmpty) &&
|
matching.targets.every((target) => target.trim().isEmpty) &&
|
||||||
matching.items.every((item) => item.text.trim().isEmpty),
|
matching.items.every((item) => item.text.trim().isEmpty),
|
||||||
|
CardType.character =>
|
||||||
|
_blank(front) &&
|
||||||
|
[
|
||||||
|
...meanings,
|
||||||
|
...onReadings,
|
||||||
|
...kunReadings,
|
||||||
|
].every((each) => each.trim().isEmpty),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Whether the card can be studied: everything its type asks for is there.
|
/// Whether the card can be studied: everything its type asks for is there.
|
||||||
|
|
@ -428,10 +471,20 @@ final class const CardContent({
|
||||||
for (var t = 0; t < matching.targets.length; t++)
|
for (var t = 0; t < matching.targets.length; t++)
|
||||||
matching.items.any((item) => item.target == t),
|
matching.items.any((item) => item.target == t),
|
||||||
].every((filled) => filled),
|
].every((filled) => filled),
|
||||||
|
// A character and something to recall of it: a kana's romaji is its
|
||||||
|
// meaning, a kanji may be known by its readings alone.
|
||||||
|
CardType.character =>
|
||||||
|
!_blank(front) &&
|
||||||
|
[
|
||||||
|
...meanings,
|
||||||
|
...onReadings,
|
||||||
|
...kunReadings,
|
||||||
|
].any((each) => each.trim().isNotEmpty),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What a list shows beside the front: the back, the right answers, the
|
/// What a list shows beside the front: the back, the right answers, the
|
||||||
/// reading and meanings, or the targets; one line.
|
/// reading and meanings, the targets, or a character's meanings and
|
||||||
|
/// readings; one line.
|
||||||
String get answerPreview => switch (type) {
|
String get answerPreview => switch (type) {
|
||||||
CardType.frontBack => _firstLine(back.plainText),
|
CardType.frontBack => _firstLine(back.plainText),
|
||||||
CardType.multipleChoice => [
|
CardType.multipleChoice => [
|
||||||
|
|
@ -443,6 +496,11 @@ final class const CardContent({
|
||||||
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
|
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
|
||||||
].where((part) => part.isNotEmpty).join(' · '),
|
].where((part) => part.isNotEmpty).join(' · '),
|
||||||
CardType.matching => matching.targets.join(' · '),
|
CardType.matching => matching.targets.join(' · '),
|
||||||
|
CardType.character => [
|
||||||
|
_joined(meanings, '; '),
|
||||||
|
_joined(onReadings, '、'),
|
||||||
|
_joined(kunReadings, '、'),
|
||||||
|
].where((part) => part.isNotEmpty).join(' · '),
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -461,9 +519,24 @@ final class const CardContent({
|
||||||
report,
|
report,
|
||||||
senses,
|
senses,
|
||||||
entry,
|
entry,
|
||||||
|
onReadings,
|
||||||
|
kunReadings,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _blank(RichContent content) => content.isBlank;
|
bool _blank(RichContent content) => content.isBlank;
|
||||||
|
|
||||||
String _firstLine(String text) => text.split('\n').first;
|
String _firstLine(String text) => text.split('\n').first;
|
||||||
|
|
||||||
|
/// The [values] that aren't blank, trimmed, joined by [separator].
|
||||||
|
String _joined(List<String> values, String separator) => [
|
||||||
|
for (final value in values)
|
||||||
|
if (value.trim().isNotEmpty) value.trim(),
|
||||||
|
].join(separator);
|
||||||
|
|
||||||
|
/// Readings as a person types them on one line: split at commas, Japanese
|
||||||
|
/// commas, semicolons or spaces, blank ones left out (`ショク、ジキ`).
|
||||||
|
List<String> splitReadings(String text) => [
|
||||||
|
for (final reading in text.split(RegExp(r'[,、,;;\s]+')))
|
||||||
|
if (reading.isNotEmpty) reading,
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -840,7 +840,8 @@ class const _Half({required final Widget child}) extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A card's question: a word with its reading over it, else its front.
|
/// A card's question: a word with its reading over it, a character large,
|
||||||
|
/// else its front.
|
||||||
class const _Front({
|
class const _Front({
|
||||||
required final CardContent content,
|
required final CardContent content,
|
||||||
required final TextStyle? style,
|
required final TextStyle? style,
|
||||||
|
|
@ -849,6 +850,17 @@ class const _Front({
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
if (content.type == CardType.character) {
|
||||||
|
return Align(
|
||||||
|
alignment: centred
|
||||||
|
? Alignment.center
|
||||||
|
: AlignmentDirectional.centerStart,
|
||||||
|
child: Text(
|
||||||
|
content.front.plainText.trim(),
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (content.type == CardType.vocabulary) {
|
if (content.type == CardType.vocabulary) {
|
||||||
final word = content.front.plainText.trim();
|
final word = content.front.plainText.trim();
|
||||||
final reading = content.reading.trim();
|
final reading = content.reading.trim();
|
||||||
|
|
@ -874,9 +886,9 @@ class const _Front({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A card's answer as its type has it: a word's meanings, a choice's
|
/// A card's answer as its type has it: a word's meanings, a character's
|
||||||
/// answers lettered (the right ones marked), a sorting's targets with their
|
/// meanings and readings, a choice's answers lettered (the right ones
|
||||||
/// items, else its back.
|
/// marked), a sorting's targets with their items, else its back.
|
||||||
class const _Back({
|
class const _Back({
|
||||||
required final CardContent content,
|
required final CardContent content,
|
||||||
required final TextStyle? style,
|
required final TextStyle? style,
|
||||||
|
|
@ -900,6 +912,19 @@ class const _Back({
|
||||||
style: style,
|
style: style,
|
||||||
textAlign: centred ? TextAlign.center : null,
|
textAlign: centred ? TextAlign.center : null,
|
||||||
);
|
);
|
||||||
|
case CardType.character:
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Text(
|
||||||
|
[
|
||||||
|
content.meanings.where((each) => each.trim().isNotEmpty).join('; '),
|
||||||
|
if (content.onReadings.isNotEmpty)
|
||||||
|
'${l10n.characterOn}: ${content.onReadings.join('、')}',
|
||||||
|
if (content.kunReadings.isNotEmpty)
|
||||||
|
'${l10n.characterKun}: ${content.kunReadings.join('、')}',
|
||||||
|
].where((line) => line.isNotEmpty).join('\n'),
|
||||||
|
style: style,
|
||||||
|
textAlign: centred ? TextAlign.center : null,
|
||||||
|
);
|
||||||
case CardType.multipleChoice:
|
case CardType.multipleChoice:
|
||||||
final success = theme.extras.success;
|
final success = theme.extras.success;
|
||||||
return Column(
|
return Column(
|
||||||
|
|
@ -1191,6 +1216,7 @@ class const _StackTile({
|
||||||
if (answer.correct) answer.content.plainText.trim(),
|
if (answer.correct) answer.content.plainText.trim(),
|
||||||
].join('; '),
|
].join('; '),
|
||||||
CardType.matching => content.matching.targets.join(', '),
|
CardType.matching => content.matching.targets.join(', '),
|
||||||
|
CardType.character => content.answerPreview,
|
||||||
CardType.frontBack => content.back.plainText.trim(),
|
CardType.frontBack => content.back.plainText.trim(),
|
||||||
};
|
};
|
||||||
Text line(String text) => Text(
|
Text line(String text) => Text(
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ String _kindName(AppLocalizations l10n, CardBrowserKind? kind) =>
|
||||||
CardBrowserKind.vocabulary => l10n.cardBrowserTypeVocabulary,
|
CardBrowserKind.vocabulary => l10n.cardBrowserTypeVocabulary,
|
||||||
CardBrowserKind.matching => l10n.cardBrowserTypeMatching,
|
CardBrowserKind.matching => l10n.cardBrowserTypeMatching,
|
||||||
CardBrowserKind.map => l10n.cardBrowserTypeMap,
|
CardBrowserKind.map => l10n.cardBrowserTypeMap,
|
||||||
|
CardBrowserKind.character => l10n.cardBrowserTypeCharacter,
|
||||||
};
|
};
|
||||||
|
|
||||||
String _statusName(AppLocalizations l10n, CardStatus status) =>
|
String _statusName(AppLocalizations l10n, CardStatus status) =>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.d
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/character_fields.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/field_label.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/field_label.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/matching_fields.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/matching_fields.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/vocabulary_fields.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/vocabulary_fields.dart';
|
||||||
|
|
@ -39,7 +40,10 @@ enum CardKind() {
|
||||||
matching,
|
matching,
|
||||||
|
|
||||||
/// A region to point at on a map: a multiple-choice card drawn on it.
|
/// A region to point at on a map: a multiple-choice card drawn on it.
|
||||||
map;
|
map,
|
||||||
|
|
||||||
|
/// A kanji or a kana.
|
||||||
|
character;
|
||||||
|
|
||||||
/// The kind of [content].
|
/// The kind of [content].
|
||||||
static CardKind of(CardContent content) => switch (content.type) {
|
static CardKind of(CardContent content) => switch (content.type) {
|
||||||
|
|
@ -47,6 +51,7 @@ enum CardKind() {
|
||||||
CardType.frontBack || CardType.multipleChoice => flashcard,
|
CardType.frontBack || CardType.multipleChoice => flashcard,
|
||||||
CardType.vocabulary => vocabulary,
|
CardType.vocabulary => vocabulary,
|
||||||
CardType.matching => matching,
|
CardType.matching => matching,
|
||||||
|
CardType.character => character,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// [content] as this kind, every field kept for switching back but the
|
/// [content] as this kind, every field kept for switching back but the
|
||||||
|
|
@ -61,6 +66,7 @@ enum CardKind() {
|
||||||
),
|
),
|
||||||
vocabulary => plain.copyWith(type: CardType.vocabulary),
|
vocabulary => plain.copyWith(type: CardType.vocabulary),
|
||||||
matching => plain.copyWith(type: CardType.matching),
|
matching => plain.copyWith(type: CardType.matching),
|
||||||
|
character => plain.copyWith(type: CardType.character),
|
||||||
// Only an answer naming a region is one on a map.
|
// Only an answer naming a region is one on a map.
|
||||||
map => content.copyWith(
|
map => content.copyWith(
|
||||||
type: CardType.multipleChoice,
|
type: CardType.multipleChoice,
|
||||||
|
|
@ -258,6 +264,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
CardKind.vocabulary => l10n.composerLabelExpression,
|
CardKind.vocabulary => l10n.composerLabelExpression,
|
||||||
CardKind.matching => l10n.composerLabelPrompt,
|
CardKind.matching => l10n.composerLabelPrompt,
|
||||||
CardKind.map => l10n.composerLabelQuestion,
|
CardKind.map => l10n.composerLabelQuestion,
|
||||||
|
CardKind.character => l10n.composerLabelCharacter,
|
||||||
};
|
};
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|
@ -318,6 +325,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
(CardKind.vocabulary, l10n.composerKindVocabulary),
|
(CardKind.vocabulary, l10n.composerKindVocabulary),
|
||||||
(CardKind.matching, l10n.composerKindMatching),
|
(CardKind.matching, l10n.composerKindMatching),
|
||||||
(CardKind.map, l10n.composerKindMap),
|
(CardKind.map, l10n.composerKindMap),
|
||||||
|
(CardKind.character, l10n.composerKindCharacter),
|
||||||
])
|
])
|
||||||
ChoiceChip(
|
ChoiceChip(
|
||||||
label: Text(name),
|
label: Text(name),
|
||||||
|
|
@ -377,6 +385,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
CardKind.vocabulary => l10n.composerPlaceholderExpression,
|
CardKind.vocabulary => l10n.composerPlaceholderExpression,
|
||||||
CardKind.matching => l10n.composerPlaceholderPrompt,
|
CardKind.matching => l10n.composerPlaceholderPrompt,
|
||||||
CardKind.map => l10n.composerPlaceholderQuestion,
|
CardKind.map => l10n.composerPlaceholderQuestion,
|
||||||
|
CardKind.character => l10n.composerPlaceholderCharacter,
|
||||||
},
|
},
|
||||||
startExpanded: widget.focusFront,
|
startExpanded: widget.focusFront,
|
||||||
onChanged: (front) => _change(_sent.copyWith(front: front)),
|
onChanged: (front) => _change(_sent.copyWith(front: front)),
|
||||||
|
|
@ -439,6 +448,18 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
onSensesChanged: (senses) =>
|
onSensesChanged: (senses) =>
|
||||||
_change(_sent.copyWith(senses: senses)),
|
_change(_sent.copyWith(senses: senses)),
|
||||||
),
|
),
|
||||||
|
CardKind.character => CharacterFields(
|
||||||
|
key: _key('character'),
|
||||||
|
meanings: content.meanings,
|
||||||
|
onReadings: content.onReadings,
|
||||||
|
kunReadings: content.kunReadings,
|
||||||
|
onMeaningsChanged: (meanings) =>
|
||||||
|
_change(_sent.copyWith(meanings: meanings)),
|
||||||
|
onOnReadingsChanged: (readings) =>
|
||||||
|
_change(_sent.copyWith(onReadings: readings)),
|
||||||
|
onKunReadingsChanged: (readings) =>
|
||||||
|
_change(_sent.copyWith(kunReadings: readings)),
|
||||||
|
),
|
||||||
CardKind.matching => MatchingFields(
|
CardKind.matching => MatchingFields(
|
||||||
key: _key('matching'),
|
key: _key('matching'),
|
||||||
matching: content.matching,
|
matching: content.matching,
|
||||||
|
|
@ -703,6 +724,8 @@ class const _Conflicts({required final CardDraft draft})
|
||||||
CardField.report => l10n.cardReport,
|
CardField.report => l10n.cardReport,
|
||||||
CardField.senses => l10n.cardSenses,
|
CardField.senses => l10n.cardSenses,
|
||||||
CardField.entry => l10n.cardDictionaryEntry,
|
CardField.entry => l10n.cardDictionaryEntry,
|
||||||
|
CardField.onReadings => l10n.characterOnReadings,
|
||||||
|
CardField.kunReadings => l10n.characterKunReadings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// A character's meanings, the main one first (a kana's romaji), and a
|
||||||
|
/// kanji's on and kun readings, each kind on one line as dictionaries list
|
||||||
|
/// them (`ショク、ジキ`). Its own text is the truth while it shows; give it
|
||||||
|
/// a key per card, so another card starts afresh.
|
||||||
|
class const CharacterFields({
|
||||||
|
/// The meanings.
|
||||||
|
required final List<String> meanings,
|
||||||
|
|
||||||
|
/// The on readings.
|
||||||
|
required final List<String> onReadings,
|
||||||
|
|
||||||
|
/// The kun readings.
|
||||||
|
required final List<String> kunReadings,
|
||||||
|
|
||||||
|
/// Called with all meanings after every change, blank rows included.
|
||||||
|
required final ValueChanged<List<String>> onMeaningsChanged,
|
||||||
|
|
||||||
|
/// Called with the on readings after every change.
|
||||||
|
required final ValueChanged<List<String>> onOnReadingsChanged,
|
||||||
|
|
||||||
|
/// Called with the kun readings after every change.
|
||||||
|
required final ValueChanged<List<String>> onKunReadingsChanged,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<CharacterFields> createState() => _CharacterFieldsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CharacterFieldsState() extends State<CharacterFields> {
|
||||||
|
late final _meanings = [
|
||||||
|
for (final meaning in widget.meanings.isEmpty ? [''] : widget.meanings)
|
||||||
|
TextEditingController(text: meaning),
|
||||||
|
];
|
||||||
|
late final _on = TextEditingController(text: widget.onReadings.join('、'));
|
||||||
|
late final _kun = TextEditingController(text: widget.kunReadings.join('、'));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final meaning in _meanings) {
|
||||||
|
meaning.dispose();
|
||||||
|
}
|
||||||
|
_on.dispose();
|
||||||
|
_kun.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitMeanings() =>
|
||||||
|
widget.onMeaningsChanged([for (final meaning in _meanings) meaning.text]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final labelStyle = theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
);
|
||||||
|
Widget readings(
|
||||||
|
String label,
|
||||||
|
String hint,
|
||||||
|
TextEditingController controller,
|
||||||
|
ValueChanged<List<String>> onChanged,
|
||||||
|
) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(label, style: labelStyle),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (text) => onChanged(splitReadings(text)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(l10n.vocabularyMeanings, style: labelStyle),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final (i, meaning) in _meanings.indexed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: meaning,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: i == 0
|
||||||
|
? l10n.characterPrimaryMeaningHint
|
||||||
|
: l10n.vocabularyMeaningHint,
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (_) => _emitMeanings(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_meanings.length > 1)
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.vocabularyRemoveMeaning,
|
||||||
|
icon: const Icon(LucideIcons.x, size: 18),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _meanings.removeAt(i).dispose());
|
||||||
|
_emitMeanings();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
setState(() => _meanings.add(TextEditingController())),
|
||||||
|
icon: const Icon(LucideIcons.plus, size: 18),
|
||||||
|
label: Text(l10n.vocabularyAddMeaning),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
readings(
|
||||||
|
l10n.characterOnReadings,
|
||||||
|
l10n.characterOnReadingsHint,
|
||||||
|
_on,
|
||||||
|
widget.onOnReadingsChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
readings(
|
||||||
|
l10n.characterKunReadings,
|
||||||
|
l10n.characterKunReadingsHint,
|
||||||
|
_kun,
|
||||||
|
widget.onKunReadingsChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -196,9 +196,9 @@ class StudysetMarkdownExporter(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [card] as an item of the flashcards' list, written to [list]: its
|
/// [card] as an item of the flashcards' list, written to [list]: its
|
||||||
/// question, and below it its answer (a word's meanings, a choice's
|
/// question, and below it its answer (a word's meanings, a character's
|
||||||
/// answers as a task list, a sorting's targets with their items), its
|
/// meanings and readings, a choice's answers as a task list, a sorting's
|
||||||
/// hints and its explanation.
|
/// targets with their items), its hints and its explanation.
|
||||||
Future<void> _card(_Export out, StringBuffer list, Card card) async {
|
Future<void> _card(_Export out, StringBuffer list, Card card) async {
|
||||||
final content = card.content;
|
final content = card.content;
|
||||||
final texts = out.texts;
|
final texts = out.texts;
|
||||||
|
|
@ -221,6 +221,11 @@ class StudysetMarkdownExporter(
|
||||||
texts.answer,
|
texts.answer,
|
||||||
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
|
meanings.where((meaning) => meaning.trim().isNotEmpty).join('; '),
|
||||||
);
|
);
|
||||||
|
case CardType.character:
|
||||||
|
// Its meanings, then its on and kun readings, as a list shows them.
|
||||||
|
out
|
||||||
|
..item(list, 0, texts.question, question)
|
||||||
|
..item(list, 1, texts.answer, content.answerPreview);
|
||||||
case CardType.multipleChoice:
|
case CardType.multipleChoice:
|
||||||
out.item(list, 0, texts.question, question);
|
out.item(list, 0, texts.question, question);
|
||||||
for (final answer in content.answers) {
|
for (final answer in content.answers) {
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,108 @@ class const VocabularyBody({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A character: large and centred, and once shown, a line, its meanings
|
||||||
|
/// (a kana's romaji) and a kanji's on and kun readings. Asking its back,
|
||||||
|
/// the meanings come first and the character with its readings answers.
|
||||||
|
class const CharacterBody({
|
||||||
|
required final Card card,
|
||||||
|
|
||||||
|
/// The row above the card's content.
|
||||||
|
required final Widget header,
|
||||||
|
required final bool asksBack,
|
||||||
|
required final bool answerShown,
|
||||||
|
|
||||||
|
/// Shows or hides the answer.
|
||||||
|
required final VoidCallback onToggle,
|
||||||
|
|
||||||
|
/// Shows the answer.
|
||||||
|
required final VoidCallback onShow,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final content = card.content;
|
||||||
|
final character = Text(
|
||||||
|
content.front.plainText.trim(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.displayLarge,
|
||||||
|
);
|
||||||
|
final meanings = Text(
|
||||||
|
[
|
||||||
|
for (final meaning in content.meanings)
|
||||||
|
if (meaning.trim().isNotEmpty) meaning.trim(),
|
||||||
|
].join(', '),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: asksBack
|
||||||
|
? theme.textTheme.headlineMedium
|
||||||
|
: theme.textTheme.headlineSmall,
|
||||||
|
);
|
||||||
|
final readings = CharacterReadings(content: content);
|
||||||
|
return StudyCardSurface(
|
||||||
|
header: header,
|
||||||
|
onTap: onToggle,
|
||||||
|
children: [
|
||||||
|
if (asksBack) meanings else character,
|
||||||
|
..._hintsPart(card),
|
||||||
|
..._answerPart(
|
||||||
|
context,
|
||||||
|
card: card,
|
||||||
|
shown: answerShown,
|
||||||
|
onShow: onShow,
|
||||||
|
answer: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (asksBack) character else meanings,
|
||||||
|
if (content.onReadings.isNotEmpty ||
|
||||||
|
content.kunReadings.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
readings,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kanji's on and kun readings, each kind on a line behind its name,
|
||||||
|
/// centred.
|
||||||
|
class const CharacterReadings({required final CardContent content, super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final label = theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
Widget line(String name, List<String> readings) => Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(text: '$name ', style: label),
|
||||||
|
TextSpan(
|
||||||
|
text: readings.join('、'),
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
if (content.onReadings.isNotEmpty)
|
||||||
|
line(l10n.characterOn, content.onReadings),
|
||||||
|
if (content.kunReadings.isNotEmpty)
|
||||||
|
line(l10n.characterKun, content.kunReadings),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A word whose reading is typed: its expression large and centred, a
|
/// A word whose reading is typed: its expression large and centred, a
|
||||||
/// field for the reading in kana (romaji turn into kana as typed, the
|
/// field for the reading in kana (romaji turn into kana as typed, the
|
||||||
/// device's own Japanese input works too), Check below the card or Enter;
|
/// device's own Japanese input works too), Check below the card or Enter;
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,31 @@ class const _Showing(
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
CardType.character => SwipeToRate(
|
||||||
|
key: key,
|
||||||
|
onRated: rate,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CharacterBody(
|
||||||
|
key: key,
|
||||||
|
card: card,
|
||||||
|
header: cardHeader,
|
||||||
|
asksBack: state.asksBack,
|
||||||
|
answerShown: state.answerShown,
|
||||||
|
onToggle: toggle,
|
||||||
|
onShow: show,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (state.ratingFailed) ...[
|
||||||
|
const RatingFailedBanner(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
CardType.frontBack => SwipeToRate(
|
CardType.frontBack => SwipeToRate(
|
||||||
key: key,
|
key: key,
|
||||||
onRated: rate,
|
onRated: rate,
|
||||||
|
|
@ -501,7 +526,7 @@ class const _Showing(
|
||||||
final ratedBelow = switch (card.content.type) {
|
final ratedBelow = switch (card.content.type) {
|
||||||
CardType.vocabulary when state.quiz != null || state.typesReading =>
|
CardType.vocabulary when state.quiz != null || state.typesReading =>
|
||||||
false,
|
false,
|
||||||
CardType.frontBack || CardType.vocabulary => true,
|
CardType.frontBack || CardType.vocabulary || CardType.character => true,
|
||||||
CardType.multipleChoice || CardType.matching => false,
|
CardType.multipleChoice || CardType.matching => false,
|
||||||
};
|
};
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ void main() {
|
||||||
entry: (source: 'jmdict', id: '1358280'),
|
entry: (source: 'jmdict', id: '1358280'),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(cardSchemaVersion, 6);
|
expect(cardSchemaVersion, greaterThanOrEqualTo(6));
|
||||||
expect(decodeCardPayload(encodeCardPayload(content)), content);
|
expect(decodeCardPayload(encodeCardPayload(content)), content);
|
||||||
expect(
|
expect(
|
||||||
decodeCardPayload(
|
decodeCardPayload(
|
||||||
|
|
@ -196,6 +196,39 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a character keeps its meanings and its on and kun readings '
|
||||||
|
'(schema 7); a card from before has no readings', () {
|
||||||
|
final content = CardContent(
|
||||||
|
type: CardType.character,
|
||||||
|
front: text('食'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
meanings: const ['eat', 'food'],
|
||||||
|
onReadings: const ['ショク', 'ジキ'],
|
||||||
|
kunReadings: const ['く.う', 'た.べる'],
|
||||||
|
entry: (source: 'kanjidic', id: '食'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cardSchemaVersion, 7);
|
||||||
|
expect(decodeCardPayload(encodeCardPayload(content)), content);
|
||||||
|
expect(
|
||||||
|
decodePayload(
|
||||||
|
encodeCardPayload(content),
|
||||||
|
newestSchema: cardSchemaVersion,
|
||||||
|
),
|
||||||
|
containsPair('on', ['ショク', 'ジキ']),
|
||||||
|
);
|
||||||
|
final before = decodeCardPayload(
|
||||||
|
bytes({
|
||||||
|
'schemaVersion': 6,
|
||||||
|
'type': 'frontBack',
|
||||||
|
'front': text('猫').toJson(),
|
||||||
|
'back': text('cat').toJson(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(before.onReadings, isEmpty);
|
||||||
|
expect(before.kunReadings, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('reads a report of a category it does not know as "other"', () {
|
test('reads a report of a category it does not know as "other"', () {
|
||||||
final content = decodeCardPayload(
|
final content = decodeCardPayload(
|
||||||
bytes({
|
bytes({
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,37 @@ void main() {
|
||||||
expect(word.withField(CardField.meanings, const [' ']).isComplete, false);
|
expect(word.withField(CardField.meanings, const [' ']).isComplete, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('character: with the character and a meaning or a reading; a '
|
||||||
|
"kana's romaji is its meaning", () {
|
||||||
|
final kanji = CardContent(
|
||||||
|
type: CardType.character,
|
||||||
|
front: text('食'),
|
||||||
|
back: none,
|
||||||
|
onReadings: const ['ショク'],
|
||||||
|
);
|
||||||
|
final kana = CardContent(
|
||||||
|
type: CardType.character,
|
||||||
|
front: text('あ'),
|
||||||
|
back: none,
|
||||||
|
meanings: const ['a'],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(kanji.isComplete, true);
|
||||||
|
expect(kana.isComplete, true);
|
||||||
|
expect(
|
||||||
|
kanji.withField(CardField.onReadings, const <String>[]).isComplete,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(kana.withField(CardField.front, none).isComplete, false);
|
||||||
|
expect(
|
||||||
|
kanji
|
||||||
|
.withField(CardField.onReadings, const <String>[])
|
||||||
|
.withField(CardField.front, none)
|
||||||
|
.isBlank,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('matching: with two targets and two items at least, every item in '
|
test('matching: with two targets and two items at least, every item in '
|
||||||
'a target and no target empty', () {
|
'a target and no target empty', () {
|
||||||
CardContent sorting(List<int> targets) => CardContent(
|
CardContent sorting(List<int> targets) => CardContent(
|
||||||
|
|
@ -120,6 +151,24 @@ void main() {
|
||||||
).answerPreview,
|
).answerPreview,
|
||||||
'Tokyo',
|
'Tokyo',
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
CardContent(
|
||||||
|
type: CardType.character,
|
||||||
|
front: text('食'),
|
||||||
|
back: none,
|
||||||
|
meanings: const ['eat', 'food'],
|
||||||
|
onReadings: const ['ショク', 'ジキ'],
|
||||||
|
kunReadings: const ['た.べる'],
|
||||||
|
).answerPreview,
|
||||||
|
'eat; food · ショク、ジキ · た.べる',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a character's readings, typed on one line, split at commas, "
|
||||||
|
'Japanese commas and spaces', () {
|
||||||
|
expect(splitReadings('ショク、ジキ'), ['ショク', 'ジキ']);
|
||||||
|
expect(splitReadings(' く.う, た.べる は.む '), ['く.う', 'た.べる', 'は.む']);
|
||||||
|
expect(splitReadings(''), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses only the fields of its type', () {
|
test('uses only the fields of its type', () {
|
||||||
|
|
@ -136,6 +185,15 @@ void main() {
|
||||||
).fields,
|
).fields,
|
||||||
{CardField.front, CardField.answers, CardField.map},
|
{CardField.front, CardField.answers, CardField.map},
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
CardContent(type: CardType.character, front: none, back: none).fields,
|
||||||
|
{
|
||||||
|
CardField.front,
|
||||||
|
CardField.meanings,
|
||||||
|
CardField.onReadings,
|
||||||
|
CardField.kunReadings,
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalized drops blank hints and a blank explanation, and takes a '
|
test('normalized drops blank hints and a blank explanation, and takes a '
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,40 @@ void main() {
|
||||||
expect(find.text('cat'), findsOneWidget);
|
expect(find.text('cat'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a character shows its meanings and readings, and the filter '
|
||||||
|
'finds characters alone', (tester) async {
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.character,
|
||||||
|
front: RichContent.plain('食'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
meanings: const ['eat'],
|
||||||
|
onReadings: const ['ショク'],
|
||||||
|
kunReadings: const ['た.べる'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await show(tester);
|
||||||
|
|
||||||
|
final answer = find.text('eat\nOn: ショク\nKun: た.べる');
|
||||||
|
expect(answer, findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Filter'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Characters only'));
|
||||||
|
await tester.tap(find.text('Apply'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('cat'), findsNothing);
|
||||||
|
expect(answer, findsOneWidget);
|
||||||
|
|
||||||
|
// A kun reading is found without its okurigana dot.
|
||||||
|
await tester.tap(find.byTooltip('Search'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byType(TextField), 'taberu');
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(answer, findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('cards chosen are deleted together, and Undo brings them '
|
testWidgets('cards chosen are deleted together, and Undo brings them '
|
||||||
'back', (tester) async {
|
'back', (tester) async {
|
||||||
await show(tester);
|
await show(tester);
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ void main() {
|
||||||
await showStudyset(tester, size: const Size(412, 915));
|
await showStudyset(tester, size: const Size(412, 915));
|
||||||
await open(tester, '猫');
|
await open(tester, '猫');
|
||||||
|
|
||||||
const types = ['Flashcard', 'Vocabulary', 'Matching', 'Map'];
|
const types = ['Flashcard', 'Vocabulary', 'Matching', 'Map', 'Character'];
|
||||||
for (final type in types) {
|
for (final type in types) {
|
||||||
final name = tester.renderObject<RenderParagraph>(find.text(type));
|
final name = tester.renderObject<RenderParagraph>(find.text(type));
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -528,6 +528,31 @@ void main() {
|
||||||
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
expect(gateway.deletions, ['blank $id', 'delete $id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a character card takes its meanings and its on and kun '
|
||||||
|
'readings, each kind on one line', (tester) async {
|
||||||
|
await cards.create('japanese', card('食', 'food'));
|
||||||
|
await showStudyset(tester);
|
||||||
|
await open(tester, '食');
|
||||||
|
|
||||||
|
await tester.tap(find.text('Character'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('On readings'), findsOneWidget);
|
||||||
|
expect(find.text('Kun readings'), findsOneWidget);
|
||||||
|
await tester.enterText(find.byType(TextField).at(0), 'eat');
|
||||||
|
await tester.enterText(find.byType(TextField).at(1), 'ショク、ジキ');
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), 'く.う, た.べる');
|
||||||
|
await tester.tap(find.byType(BackButton));
|
||||||
|
await settle(tester);
|
||||||
|
|
||||||
|
final saved = (await cards.cardsOf('japanese')).single.content;
|
||||||
|
expect(saved.type, CardType.character);
|
||||||
|
expect(saved.front.plainText, '食');
|
||||||
|
expect(saved.meanings, ['eat']);
|
||||||
|
expect(saved.onReadings, ['ショク', 'ジキ']);
|
||||||
|
expect(saved.kunReadings, ['く.う', 'た.べる']);
|
||||||
|
expect(saved.back, RichContent.empty());
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a map question takes the region tapped as its answer', (
|
testWidgets('a map question takes the region tapped as its answer', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue