feat(study): a kanji's answer shows the studyset's words written with it
A character card's answer lists up to five vocabulary cards of the studysets the session studies whose front holds the character, with their reading and first meanings; a tap opens the word's card. The words come from every card the session reads, whatever tags narrow it, so a session of kanji alone still shows them, and nothing is fetched: it works offline. 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
a66357ab2a
commit
c7486a5b1e
13 changed files with 296 additions and 7 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 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.
|
||||||
- **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). **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. **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.
|
- **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.
|
||||||
- **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.
|
||||||
|
|
|
||||||
|
|
@ -1497,5 +1497,6 @@
|
||||||
"suggestIntro": "Im Original sieht man es so, wie du es jetzt hast, neben der eigenen Fassung. Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.",
|
"suggestIntro": "Im Original sieht man es so, wie du es jetzt hast, neben der eigenen Fassung. Öffentlich: alle, die es lernen, lesen es, mit deinem öffentlichen Profil.",
|
||||||
"suggestSent": "Vorschlag gesendet.",
|
"suggestSent": "Vorschlag gesendet.",
|
||||||
"suggestNoDerivatives": "Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.",
|
"suggestNoDerivatives": "Seine Lizenz verbietet Bearbeitungen: keine Vorschläge.",
|
||||||
"feedbackNeedsIdentity": "Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen."
|
"feedbackNeedsIdentity": "Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.",
|
||||||
|
"kanjiExampleWords": "Wörter damit"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1748,5 +1748,6 @@
|
||||||
"suggestIntro": "The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.",
|
"suggestIntro": "The author sees it as you have it now, beside theirs. Public: everyone learning it reads it, with your public profile.",
|
||||||
"suggestSent": "Suggestion sent.",
|
"suggestSent": "Suggestion sent.",
|
||||||
"suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.",
|
"suggestNoDerivatives": "Its licence forbids adaptations: no suggestions.",
|
||||||
"feedbackNeedsIdentity": "Sign in with GitHub to report to the author and suggest."
|
"feedbackNeedsIdentity": "Sign in with GitHub to report to the author and suggest.",
|
||||||
|
"kanjiExampleWords": "Words with it"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8894,6 +8894,12 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Sign in with GitHub to report to the author and suggest.'**
|
/// **'Sign in with GitHub to report to the author and suggest.'**
|
||||||
String get feedbackNeedsIdentity;
|
String get feedbackNeedsIdentity;
|
||||||
|
|
||||||
|
/// No description provided for @kanjiExampleWords.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Words with it'**
|
||||||
|
String get kanjiExampleWords;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -5560,4 +5560,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get feedbackNeedsIdentity =>
|
String get feedbackNeedsIdentity =>
|
||||||
'Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.';
|
'Melde dich mit GitHub an, um im Original zu melden und vorzuschlagen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kanjiExampleWords => 'Wörter damit';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5511,6 +5511,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get feedbackNeedsIdentity =>
|
String get feedbackNeedsIdentity =>
|
||||||
'Sign in with GitHub to report to the author and suggest.';
|
'Sign in with GitHub to report to the author and suggest.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get kanjiExampleWords => 'Words with it';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_study_mode.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/card_study_mode.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/example_word.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
|
||||||
|
|
@ -111,6 +112,10 @@ class StudySessionBloc(
|
||||||
var _round = <String>[];
|
var _round = <String>[];
|
||||||
var _cardsById = <String, Card>{};
|
var _cardsById = <String, Card>{};
|
||||||
|
|
||||||
|
/// The words of the studied studysets, whatever the tags chosen: the
|
||||||
|
/// examples a character's answer shows.
|
||||||
|
var _words = <ExampleWord>[];
|
||||||
|
|
||||||
/// The studyset each card of the session is in.
|
/// The studyset each card of the session is in.
|
||||||
final _studysetOf = <String, String>{};
|
final _studysetOf = <String, String>{};
|
||||||
|
|
||||||
|
|
@ -359,18 +364,27 @@ class StudySessionBloc(
|
||||||
_order = optionsOf(_studysetId)?.order ?? NewCardOrder.reviewsFirst;
|
_order = optionsOf(_studysetId)?.order ?? NewCardOrder.reviewsFirst;
|
||||||
_studysetOf.clear();
|
_studysetOf.clear();
|
||||||
final groups = <List<Card>>[];
|
final groups = <List<Card>>[];
|
||||||
|
final words = <ExampleWord>[];
|
||||||
for (final studysetId in studied) {
|
for (final studysetId in studied) {
|
||||||
final group = [
|
final read = [
|
||||||
for (final card in await _cards.cardsOf(studysetId))
|
for (final card in await _cards.cardsOf(studysetId))
|
||||||
if ((tagged == null || tagged.contains(card.id)) &&
|
if (!_deletions.pending.contains(card.id)) card,
|
||||||
!_deletions.pending.contains(card.id))
|
];
|
||||||
card,
|
words.addAll([
|
||||||
|
for (final card in read)
|
||||||
|
if (card.content.type == CardType.vocabulary)
|
||||||
|
(cardId: card.id, studysetId: studysetId, content: card.content),
|
||||||
|
]);
|
||||||
|
final group = [
|
||||||
|
for (final card in read)
|
||||||
|
if (tagged == null || tagged.contains(card.id)) card,
|
||||||
];
|
];
|
||||||
for (final card in group) {
|
for (final card in group) {
|
||||||
_studysetOf[card.id] = studysetId;
|
_studysetOf[card.id] = studysetId;
|
||||||
}
|
}
|
||||||
groups.add(group);
|
groups.add(group);
|
||||||
}
|
}
|
||||||
|
_words = words;
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,6 +504,13 @@ class StudySessionBloc(
|
||||||
: null,
|
: null,
|
||||||
typesReading: _typesReading(card),
|
typesReading: _typesReading(card),
|
||||||
writes: _writes(card),
|
writes: _writes(card),
|
||||||
|
examples: card.content.type == CardType.character
|
||||||
|
? exampleWordsOf(
|
||||||
|
card.content.front.plainText.trim(),
|
||||||
|
_words,
|
||||||
|
exceptId: card.id,
|
||||||
|
)
|
||||||
|
: const [],
|
||||||
studysetId: _studysetOf[id] ?? _studysetId,
|
studysetId: _studysetOf[id] ?? _studysetId,
|
||||||
tags: _tagging.of(id),
|
tags: _tagging.of(id),
|
||||||
asksBack: _asksBack(card),
|
asksBack: _asksBack(card),
|
||||||
|
|
@ -1106,6 +1127,9 @@ final class const StudySessionStateShowing({
|
||||||
/// Whether the character, or the word, is to be written by hand.
|
/// Whether the character, or the word, is to be written by hand.
|
||||||
final bool writes = false,
|
final bool writes = false,
|
||||||
|
|
||||||
|
/// A character's words in the studyset, to show with its answer.
|
||||||
|
final List<ExampleWord> examples = const [],
|
||||||
|
|
||||||
/// The studyset it is in.
|
/// The studyset it is in.
|
||||||
required final String studysetId,
|
required final String studysetId,
|
||||||
|
|
||||||
|
|
@ -1177,6 +1201,7 @@ final class const StudySessionStateShowing({
|
||||||
quiz: quiz,
|
quiz: quiz,
|
||||||
typesReading: typesReading,
|
typesReading: typesReading,
|
||||||
writes: writes,
|
writes: writes,
|
||||||
|
examples: examples,
|
||||||
studysetId: studysetId,
|
studysetId: studysetId,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
asksBack: asksBack,
|
asksBack: asksBack,
|
||||||
|
|
@ -1204,6 +1229,7 @@ final class const StudySessionStateShowing({
|
||||||
quiz,
|
quiz,
|
||||||
typesReading,
|
typesReading,
|
||||||
writes,
|
writes,
|
||||||
|
examples,
|
||||||
studysetId,
|
studysetId,
|
||||||
tags,
|
tags,
|
||||||
asksBack,
|
asksBack,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
|
||||||
|
/// A word of the studyset that uses a character studied: its card, the
|
||||||
|
/// studyset it is in, and what it holds.
|
||||||
|
typedef ExampleWord = ({String cardId, String studysetId, CardContent content});
|
||||||
|
|
||||||
|
/// How many words a character's answer shows at most.
|
||||||
|
const maxExampleWords = 5;
|
||||||
|
|
||||||
|
/// The words of [words] that use [character], up to [maxExampleWords], in
|
||||||
|
/// their order; [exceptId] (the character's own card) left out.
|
||||||
|
List<ExampleWord> exampleWordsOf(
|
||||||
|
String character,
|
||||||
|
Iterable<ExampleWord> words, {
|
||||||
|
String? exceptId,
|
||||||
|
}) => character.isEmpty
|
||||||
|
? const []
|
||||||
|
: [
|
||||||
|
for (final word in words)
|
||||||
|
if (word.cardId != exceptId &&
|
||||||
|
word.content.type == CardType.vocabulary &&
|
||||||
|
word.content.front.plainText.contains(character))
|
||||||
|
word,
|
||||||
|
].take(maxExampleWords).toList();
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_sheet.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_page_chip.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_page_chip.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/example_word.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/overflowing_tag_chips_row.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/overflowing_tag_chips_row.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
@ -336,6 +337,12 @@ class const CharacterBody({
|
||||||
|
|
||||||
/// Shows the answer.
|
/// Shows the answer.
|
||||||
required final VoidCallback onShow,
|
required final VoidCallback onShow,
|
||||||
|
|
||||||
|
/// The studyset's words that use the character, shown with the answer.
|
||||||
|
final List<ExampleWord> examples = const [],
|
||||||
|
|
||||||
|
/// Opens a word's card.
|
||||||
|
final void Function(ExampleWord word)? onOpenExample,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -378,6 +385,10 @@ class const CharacterBody({
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
readings,
|
readings,
|
||||||
],
|
],
|
||||||
|
if (examples.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_ExampleWords(words: examples, onOpen: onOpenExample),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Center(
|
Center(
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
|
|
@ -403,6 +414,63 @@ class const CharacterBody({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A character's words in the studyset, each its writing, reading and
|
||||||
|
/// first meanings, opening its card.
|
||||||
|
class const _ExampleWords({
|
||||||
|
required final List<ExampleWord> words,
|
||||||
|
required final void Function(ExampleWord word)? onOpen,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final muted = theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context).kanjiExampleWords,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
for (final word in words)
|
||||||
|
InkWell(
|
||||||
|
onTap: onOpen == null ? null : () => onOpen!(word),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: word.content.front.plainText.trim(),
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
if (word.content.reading.trim().isNotEmpty)
|
||||||
|
TextSpan(
|
||||||
|
text: ' ${word.content.reading.trim()}',
|
||||||
|
style: muted,
|
||||||
|
),
|
||||||
|
if (word.content.meanings.isNotEmpty)
|
||||||
|
TextSpan(
|
||||||
|
text: ' ${word.content.meanings.take(2).join(', ')}',
|
||||||
|
style: muted,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A kanji's on and kun readings, each kind on a line behind its name,
|
/// A kanji's on and kun readings, each kind on a line behind its name,
|
||||||
/// centred.
|
/// centred.
|
||||||
class const CharacterReadings({required final CardContent content, super.key})
|
class const CharacterReadings({required final CardContent content, super.key})
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,10 @@ class const _Showing(
|
||||||
answerShown: state.answerShown,
|
answerShown: state.answerShown,
|
||||||
onToggle: toggle,
|
onToggle: toggle,
|
||||||
onShow: show,
|
onShow: show,
|
||||||
|
examples: state.examples,
|
||||||
|
onOpenExample: (word) => context.push(
|
||||||
|
RoutePaths.card(word.studysetId, word.cardId),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
|
||||||
|
|
@ -1187,4 +1187,50 @@ void main() {
|
||||||
[StudyMode.review.value, StudyMode.reset.value],
|
[StudyMode.review.value, StudyMode.reset.value],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a character brings the studyset's words written with it, those "
|
||||||
|
'inside it too, whatever the tags chosen', () async {
|
||||||
|
CardContent word(String front) => CardContent(
|
||||||
|
type: CardType.vocabulary,
|
||||||
|
front: RichContent.plain(front),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
);
|
||||||
|
final kanji = await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.character,
|
||||||
|
front: RichContent.plain('食'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await cards.create('japanese', word('食べる'));
|
||||||
|
await cards.create('japanese', word('猫'));
|
||||||
|
final grammar = await studysets.create(
|
||||||
|
StudysetContent.titled('Grammar'),
|
||||||
|
parentId: 'japanese',
|
||||||
|
);
|
||||||
|
await cards.create(grammar, word('食堂'));
|
||||||
|
final tag = await tags.create('kanji');
|
||||||
|
await tags.setTagsOf(kanji, {tag});
|
||||||
|
await settings.update(
|
||||||
|
(s) => s.withStudyTags('japanese', [(id: tag, name: 'kanji')]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await withClock(Clock(() => now), () async {
|
||||||
|
final bloc = session();
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
final shown = await until(
|
||||||
|
bloc,
|
||||||
|
(s) => s is StudySessionStateShowing,
|
||||||
|
) as StudySessionStateShowing;
|
||||||
|
|
||||||
|
expect(shown.card.id, kanji);
|
||||||
|
expect(shown.pills, hasLength(1));
|
||||||
|
expect(
|
||||||
|
[for (final word in shown.examples) word.content.front.plainText],
|
||||||
|
['食べる', '食堂'],
|
||||||
|
);
|
||||||
|
expect(shown.examples.last.studysetId, grammar);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/example_word.dart';
|
||||||
|
|
||||||
|
/// A card [id] of the type [type] whose front is [front].
|
||||||
|
ExampleWord word(
|
||||||
|
String id,
|
||||||
|
String front, {
|
||||||
|
CardType type = CardType.vocabulary,
|
||||||
|
}) => (
|
||||||
|
cardId: id,
|
||||||
|
studysetId: 'japanese',
|
||||||
|
content: CardContent(
|
||||||
|
type: type,
|
||||||
|
front: RichContent.plain(front),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('the words written with a character, in their order', () {
|
||||||
|
final words = [
|
||||||
|
word('1', '食べる'),
|
||||||
|
word('2', '猫'),
|
||||||
|
word('3', '食事'),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
[
|
||||||
|
for (final found in exampleWordsOf('食', words)) found.cardId,
|
||||||
|
],
|
||||||
|
['1', '3'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('neither the character itself nor anything but words', () {
|
||||||
|
final words = [
|
||||||
|
word('kanji', '食', type: CardType.character),
|
||||||
|
word('own', '食'),
|
||||||
|
word('plain', '食', type: CardType.frontBack),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(exampleWordsOf('食', words, exceptId: 'own'), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('at most five', () {
|
||||||
|
final words = [for (var i = 0; i < 8; i++) word('$i', '食$i')];
|
||||||
|
|
||||||
|
expect(exampleWordsOf('食', words), hasLength(maxExampleWords));
|
||||||
|
expect(maxExampleWords, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('none for a card without a character', () {
|
||||||
|
expect(exampleWordsOf('', [word('1', '食べる')]), isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
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/card_composer_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/region_map.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
|
||||||
|
|
@ -549,6 +550,55 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets("with the answer, a character shows the studyset's words "
|
||||||
|
'written with it, each opening its card', (tester) async {
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.character,
|
||||||
|
front: paragraph('食'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
meanings: const ['eat'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.vocabulary,
|
||||||
|
front: paragraph('食べる'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
reading: 'たべる',
|
||||||
|
meanings: const ['to eat'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await cards.create(
|
||||||
|
'japanese',
|
||||||
|
CardContent(
|
||||||
|
type: CardType.vocabulary,
|
||||||
|
front: paragraph('猫'),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
reading: 'ねこ',
|
||||||
|
meanings: const ['cat'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await study(tester);
|
||||||
|
expect(find.text('Words with it'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Show answer'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Words with it'), findsOneWidget);
|
||||||
|
final word = find.textContaining('食べる たべる', findRichText: true);
|
||||||
|
expect(word, findsOneWidget);
|
||||||
|
expect(find.textContaining('猫', findRichText: true), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(word);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(CardComposerScreen), findsOneWidget);
|
||||||
|
expect(find.text('たべる'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
group('Writing by hand', () {
|
group('Writing by hand', () {
|
||||||
late FakeReviewGateway reviews;
|
late FakeReviewGateway reviews;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue