feat(app): the dictionary in the app: look words up, add them as cards
A studyset's dictionary follows from its language, as decided: the
instance's dictionary of its front's language, with meanings in its
back's (else English). Nothing to attach or detach; nothing of it shows
where there is none.
- Add material › From the dictionary: search a word (kanji, kana,
romaji, meaning) and add it as a flashcard, or add a whole level
(JLPT N5…N1, common words) at once, asked first above 500 words, its
progress shown. Words already in the studyset show as such and are
left out. Each card is a vocabulary card (headword, reading, senses
with their codes), tagged `jlpt-n5` and with its word class, and
marked as imported from the dictionary.
- Look up beside a vocabulary card's front fills the card with the word
chosen, keeping its hints, explanation and tags.
- A card keeps the dictionary entry it was made from (card schema 6:
`dictionary` {source, id}), to be found again and not added twice.
- The dictionary's credits show at its foot and in About, as its licence
asks.
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
7008fcac52
commit
add1c76842
31 changed files with 2291 additions and 14 deletions
|
|
@ -144,7 +144,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||||
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; 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, so loading a newer version never touches cards. Every file carries its credits, which the app shows.
|
- **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; 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). `DictionaryAdder` makes the cards, each entry once per studyset, tagged `jlpt-nX` and with its word class by the first sense.
|
||||||
- **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).
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,29 @@
|
||||||
"cardNextHint": "Nächster Hinweis",
|
"cardNextHint": "Nächster Hinweis",
|
||||||
"cardReport": "Meldung",
|
"cardReport": "Meldung",
|
||||||
"cardSenses": "Wortbedeutungen",
|
"cardSenses": "Wortbedeutungen",
|
||||||
|
"cardDictionaryEntry": "Wörterbucheintrag",
|
||||||
|
"studysetFromDictionary": "Aus dem Wörterbuch",
|
||||||
|
"dictionaryTitle": "Wörterbuch",
|
||||||
|
"dictionarySearchHint": "Ein Wort in Kanji, Kana oder Romaji, oder eine Bedeutung",
|
||||||
|
"dictionaryNothingFound": "Nichts gefunden für „{query}“.",
|
||||||
|
"dictionaryUnreachable": "Das Wörterbuch ist gerade nicht erreichbar.",
|
||||||
|
"dictionaryRetry": "Erneut versuchen",
|
||||||
|
"dictionaryAdd": "Hinzufügen",
|
||||||
|
"dictionaryInStudyset": "In diesem Lernset",
|
||||||
|
"dictionaryAdded": "„{word}“ hinzugefügt.",
|
||||||
|
"dictionaryLevels": "Eine ganze Stufe hinzufügen",
|
||||||
|
"dictionaryLevelsHint": "Jedes Wort einer Stufe wird eine Karteikarte, in der Reihenfolge ihrer Liste, mit Stufe und Wortart als Tags. Wörter, die schon in diesem Lernset sind, werden ausgelassen.",
|
||||||
|
"dictionaryLabelCommon": "Häufig",
|
||||||
|
"dictionaryAddLevel": "{count, plural, =1{1 Wort hinzufügen} other{{count} Wörter hinzufügen}}",
|
||||||
|
"dictionaryAddingLevel": "Wird hinzugefügt… {done} von {count}",
|
||||||
|
"dictionaryWordsAdded": "{count, plural, =0{Keine neuen Wörter.} =1{1 Wort hinzugefügt.} other{{count} Wörter hinzugefügt.}}",
|
||||||
|
"dictionaryWordsSkipped": "{count, plural, =1{1 war schon in diesem Lernset.} other{{count} waren schon in diesem Lernset.}}",
|
||||||
|
"dictionaryConfirmTitle": "{count} Wörter hinzufügen?",
|
||||||
|
"dictionaryConfirmMessage": "Jedes wird eine Karteikarte in diesem Lernset.",
|
||||||
|
"dictionaryConfirmAdd": "Hinzufügen",
|
||||||
|
"dictionaryCredits": "Wörter aus {sources}.",
|
||||||
|
"dictionaryLookUp": "Im Wörterbuch nachschlagen",
|
||||||
|
"aboutDictionaries": "Wörterbücher dieser Instanz",
|
||||||
"cardReportTitle": "Fehler melden",
|
"cardReportTitle": "Fehler melden",
|
||||||
"cardReportSubtitle": "Markiere diese Karte, um sie später zu finden und zu korrigieren.",
|
"cardReportSubtitle": "Markiere diese Karte, um sie später zu finden und zu korrigieren.",
|
||||||
"cardReportTypo": "Tippfehler",
|
"cardReportTypo": "Tippfehler",
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,37 @@
|
||||||
"cardNextHint": "Next hint",
|
"cardNextHint": "Next hint",
|
||||||
"cardReport": "Report",
|
"cardReport": "Report",
|
||||||
"cardSenses": "Senses",
|
"cardSenses": "Senses",
|
||||||
|
"cardDictionaryEntry": "Dictionary entry",
|
||||||
|
"studysetFromDictionary": "From the dictionary",
|
||||||
|
"dictionaryTitle": "Dictionary",
|
||||||
|
"dictionarySearchHint": "A word in kanji, kana or romaji, or a meaning",
|
||||||
|
"dictionaryNothingFound": "Nothing found for “{query}”.",
|
||||||
|
"@dictionaryNothingFound": {"placeholders": {"query": {"type": "String"}}},
|
||||||
|
"dictionaryUnreachable": "The dictionary can't be reached right now.",
|
||||||
|
"dictionaryRetry": "Retry",
|
||||||
|
"dictionaryAdd": "Add",
|
||||||
|
"dictionaryInStudyset": "In this studyset",
|
||||||
|
"dictionaryAdded": "Added “{word}”.",
|
||||||
|
"@dictionaryAdded": {"placeholders": {"word": {"type": "String"}}},
|
||||||
|
"dictionaryLevels": "Add a whole level",
|
||||||
|
"dictionaryLevelsHint": "Every word of a level becomes a flashcard, in the order of the level's list, tagged with its level and word class. Words already in this studyset are left out.",
|
||||||
|
"dictionaryLabelCommon": "Common",
|
||||||
|
"dictionaryAddLevel": "{count, plural, =1{Add 1 word} other{Add {count} words}}",
|
||||||
|
"@dictionaryAddLevel": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"dictionaryAddingLevel": "Adding… {done} of {count}",
|
||||||
|
"@dictionaryAddingLevel": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
|
||||||
|
"dictionaryWordsAdded": "{count, plural, =0{No new words.} =1{Added 1 word.} other{Added {count} words.}}",
|
||||||
|
"@dictionaryWordsAdded": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"dictionaryWordsSkipped": "{count, plural, =1{1 was in this studyset already.} other{{count} were in this studyset already.}}",
|
||||||
|
"@dictionaryWordsSkipped": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"dictionaryConfirmTitle": "Add {count} words?",
|
||||||
|
"@dictionaryConfirmTitle": {"placeholders": {"count": {"type": "int"}}},
|
||||||
|
"dictionaryConfirmMessage": "Each becomes a flashcard in this studyset.",
|
||||||
|
"dictionaryConfirmAdd": "Add",
|
||||||
|
"dictionaryCredits": "Words from {sources}.",
|
||||||
|
"@dictionaryCredits": {"placeholders": {"sources": {"type": "String"}}},
|
||||||
|
"dictionaryLookUp": "Look up in the dictionary",
|
||||||
|
"aboutDictionaries": "Dictionaries on this instance",
|
||||||
"cardReportTitle": "Report an error",
|
"cardReportTitle": "Report an error",
|
||||||
"cardReportSubtitle": "Flag this card so you can find and fix it later.",
|
"cardReportSubtitle": "Flag this card so you can find and fix it later.",
|
||||||
"cardReportTypo": "Typo",
|
"cardReportTypo": "Typo",
|
||||||
|
|
|
||||||
|
|
@ -2944,6 +2944,144 @@ abstract class AppLocalizations {
|
||||||
/// **'Senses'**
|
/// **'Senses'**
|
||||||
String get cardSenses;
|
String get cardSenses;
|
||||||
|
|
||||||
|
/// No description provided for @cardDictionaryEntry.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dictionary entry'**
|
||||||
|
String get cardDictionaryEntry;
|
||||||
|
|
||||||
|
/// No description provided for @studysetFromDictionary.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'From the dictionary'**
|
||||||
|
String get studysetFromDictionary;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dictionary'**
|
||||||
|
String get dictionaryTitle;
|
||||||
|
|
||||||
|
/// No description provided for @dictionarySearchHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'A word in kanji, kana or romaji, or a meaning'**
|
||||||
|
String get dictionarySearchHint;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryNothingFound.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nothing found for “{query}”.'**
|
||||||
|
String dictionaryNothingFound(String query);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryUnreachable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The dictionary can\'t be reached right now.'**
|
||||||
|
String get dictionaryUnreachable;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryRetry.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Retry'**
|
||||||
|
String get dictionaryRetry;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryAdd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add'**
|
||||||
|
String get dictionaryAdd;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryInStudyset.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'In this studyset'**
|
||||||
|
String get dictionaryInStudyset;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryAdded.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Added “{word}”.'**
|
||||||
|
String dictionaryAdded(String word);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryLevels.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add a whole level'**
|
||||||
|
String get dictionaryLevels;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryLevelsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Every word of a level becomes a flashcard, in the order of the level\'s list, tagged with its level and word class. Words already in this studyset are left out.'**
|
||||||
|
String get dictionaryLevelsHint;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryLabelCommon.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Common'**
|
||||||
|
String get dictionaryLabelCommon;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryAddLevel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{Add 1 word} other{Add {count} words}}'**
|
||||||
|
String dictionaryAddLevel(int count);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryAddingLevel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Adding… {done} of {count}'**
|
||||||
|
String dictionaryAddingLevel(int done, int count);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryWordsAdded.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =0{No new words.} =1{Added 1 word.} other{Added {count} words.}}'**
|
||||||
|
String dictionaryWordsAdded(int count);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryWordsSkipped.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 was in this studyset already.} other{{count} were in this studyset already.}}'**
|
||||||
|
String dictionaryWordsSkipped(int count);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryConfirmTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add {count} words?'**
|
||||||
|
String dictionaryConfirmTitle(int count);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryConfirmMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Each becomes a flashcard in this studyset.'**
|
||||||
|
String get dictionaryConfirmMessage;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryConfirmAdd.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add'**
|
||||||
|
String get dictionaryConfirmAdd;
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryCredits.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Words from {sources}.'**
|
||||||
|
String dictionaryCredits(String sources);
|
||||||
|
|
||||||
|
/// No description provided for @dictionaryLookUp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Look up in the dictionary'**
|
||||||
|
String get dictionaryLookUp;
|
||||||
|
|
||||||
|
/// No description provided for @aboutDictionaries.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dictionaries on this instance'**
|
||||||
|
String get aboutDictionaries;
|
||||||
|
|
||||||
/// No description provided for @cardReportTitle.
|
/// No description provided for @cardReportTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1743,6 +1743,114 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cardSenses => 'Wortbedeutungen';
|
String get cardSenses => 'Wortbedeutungen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardDictionaryEntry => 'Wörterbucheintrag';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFromDictionary => 'Aus dem Wörterbuch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryTitle => 'Wörterbuch';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionarySearchHint =>
|
||||||
|
'Ein Wort in Kanji, Kana oder Romaji, oder eine Bedeutung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryNothingFound(String query) {
|
||||||
|
return 'Nichts gefunden für „$query“.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryUnreachable =>
|
||||||
|
'Das Wörterbuch ist gerade nicht erreichbar.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryRetry => 'Erneut versuchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryAdd => 'Hinzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryInStudyset => 'In diesem Lernset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryAdded(String word) {
|
||||||
|
return '„$word“ hinzugefügt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLevels => 'Eine ganze Stufe hinzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLevelsHint =>
|
||||||
|
'Jedes Wort einer Stufe wird eine Karteikarte, in der Reihenfolge ihrer Liste, mit Stufe und Wortart als Tags. Wörter, die schon in diesem Lernset sind, werden ausgelassen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLabelCommon => 'Häufig';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryAddLevel(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Wörter hinzufügen',
|
||||||
|
one: '1 Wort hinzufügen',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryAddingLevel(int done, int count) {
|
||||||
|
return 'Wird hinzugefügt… $done von $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryWordsAdded(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Wörter hinzugefügt.',
|
||||||
|
one: '1 Wort hinzugefügt.',
|
||||||
|
zero: 'Keine neuen Wörter.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryWordsSkipped(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count waren schon in diesem Lernset.',
|
||||||
|
one: '1 war schon in diesem Lernset.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryConfirmTitle(int count) {
|
||||||
|
return '$count Wörter hinzufügen?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryConfirmMessage =>
|
||||||
|
'Jedes wird eine Karteikarte in diesem Lernset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryConfirmAdd => 'Hinzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryCredits(String sources) {
|
||||||
|
return 'Wörter aus $sources.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLookUp => 'Im Wörterbuch nachschlagen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutDictionaries => 'Wörterbücher dieser Instanz';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cardReportTitle => 'Fehler melden';
|
String get cardReportTitle => 'Fehler melden';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1732,6 +1732,114 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cardSenses => 'Senses';
|
String get cardSenses => 'Senses';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cardDictionaryEntry => 'Dictionary entry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFromDictionary => 'From the dictionary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryTitle => 'Dictionary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionarySearchHint =>
|
||||||
|
'A word in kanji, kana or romaji, or a meaning';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryNothingFound(String query) {
|
||||||
|
return 'Nothing found for “$query”.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryUnreachable =>
|
||||||
|
'The dictionary can\'t be reached right now.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryRetry => 'Retry';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryAdd => 'Add';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryInStudyset => 'In this studyset';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryAdded(String word) {
|
||||||
|
return 'Added “$word”.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLevels => 'Add a whole level';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLevelsHint =>
|
||||||
|
'Every word of a level becomes a flashcard, in the order of the level\'s list, tagged with its level and word class. Words already in this studyset are left out.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLabelCommon => 'Common';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryAddLevel(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Add $count words',
|
||||||
|
one: 'Add 1 word',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryAddingLevel(int done, int count) {
|
||||||
|
return 'Adding… $done of $count';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryWordsAdded(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Added $count words.',
|
||||||
|
one: 'Added 1 word.',
|
||||||
|
zero: 'No new words.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryWordsSkipped(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count were in this studyset already.',
|
||||||
|
one: '1 was in this studyset already.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryConfirmTitle(int count) {
|
||||||
|
return 'Add $count words?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryConfirmMessage =>
|
||||||
|
'Each becomes a flashcard in this studyset.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryConfirmAdd => 'Add';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String dictionaryCredits(String sources) {
|
||||||
|
return 'Words from $sources.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dictionaryLookUp => 'Look up in the dictionary';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutDictionaries => 'Dictionaries on this instance';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cardReportTitle => 'Report an error';
|
String get cardReportTitle => 'Report an error';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_composer_screen.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/home/widget/home_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_editor_bloc.dart';
|
||||||
|
|
@ -92,6 +93,18 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
|
||||||
noteId: state.pathParameters['noteId'],
|
noteId: state.pathParameters['noteId'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// The dictionary of a studyset's language, to add words from.
|
||||||
|
GoRoute(
|
||||||
|
path: '/studysets/:studysetId/dictionary',
|
||||||
|
builder: (context, state) {
|
||||||
|
final studysetId = state.pathParameters['studysetId']!;
|
||||||
|
return _inStudyset(
|
||||||
|
context,
|
||||||
|
studysetId,
|
||||||
|
(_, _) => DictionaryScreen(studysetId: studysetId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
// A learning path: worked through, written, and one of its steps
|
// A learning path: worked through, written, and one of its steps
|
||||||
// written.
|
// written.
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.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/data/sync_card_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/serverpod_dictionary_gateway.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/learning_path/data/learning_path_deletions.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||||
|
|
@ -78,6 +80,9 @@ class Dependencies({
|
||||||
/// What the instance says about itself, such as where its source is.
|
/// What the instance says about itself, such as where its source is.
|
||||||
required final AboutRepository about,
|
required final AboutRepository about,
|
||||||
|
|
||||||
|
/// The dictionaries the instance holds.
|
||||||
|
required final DictionaryRepository dictionaries,
|
||||||
|
|
||||||
/// Opens a signed-in person's local data (see `PersonScope`).
|
/// Opens a signed-in person's local data (see `PersonScope`).
|
||||||
required final OpenPerson openPerson,
|
required final OpenPerson openPerson,
|
||||||
|
|
||||||
|
|
@ -156,6 +161,7 @@ class Dependencies({
|
||||||
serverName: Uri.parse(client.host).host,
|
serverName: Uri.parse(client.host).host,
|
||||||
auth: AuthRepository(authGateway),
|
auth: AuthRepository(authGateway),
|
||||||
about: AboutRepository(ServerpodAboutGateway(client)),
|
about: AboutRepository(ServerpodAboutGateway(client)),
|
||||||
|
dictionaries: DictionaryRepository(ServerpodDictionaryGateway(client)),
|
||||||
maps: GeoMapRepository(),
|
maps: GeoMapRepository(),
|
||||||
sounds: sounds,
|
sounds: sounds,
|
||||||
files: FileSelectorFilePicker(),
|
files: FileSelectorFilePicker(),
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ abstract final class RoutePaths() {
|
||||||
static String note(String studysetId, String noteId) =>
|
static String note(String studysetId, String noteId) =>
|
||||||
'/studysets/$studysetId/notes/$noteId';
|
'/studysets/$studysetId/notes/$noteId';
|
||||||
|
|
||||||
|
/// The dictionary of a studyset's language, to add words from.
|
||||||
|
static String dictionary(String studysetId) =>
|
||||||
|
'/studysets/$studysetId/dictionary';
|
||||||
|
|
||||||
/// A PDF, to read, over the tabs; at [page] when given, else where reading
|
/// A PDF, to read, over the tabs; at [page] when given, else where reading
|
||||||
/// left off. Cards and screenshots tied to a page name only the PDF, which
|
/// left off. Cards and screenshots tied to a page name only the PDF, which
|
||||||
/// knows its studyset.
|
/// knows its studyset.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/bloc/about_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/bloc/about_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
/// Shows what Halkyon Learn is under which licences, and where the source of
|
/// Shows what Halkyon Learn is under which licences, where the source of
|
||||||
/// what this instance runs is; its "View licenses" lists every package's
|
/// what this instance runs is, and whose work its dictionaries are, as their
|
||||||
/// notice, which the BSD and MIT packages require in a distributed app.
|
/// licences ask; its "View licenses" lists every package's notice, which the
|
||||||
|
/// BSD and MIT packages require in a distributed app, and the fonts'.
|
||||||
void showAbout(BuildContext context, AboutRepository about) => showAboutDialog(
|
void showAbout(BuildContext context, AboutRepository about) => showAboutDialog(
|
||||||
context: context,
|
context: context,
|
||||||
applicationName: AppLocalizations.of(context).appTitle,
|
applicationName: AppLocalizations.of(context).appTitle,
|
||||||
|
|
@ -16,9 +19,63 @@ void showAbout(BuildContext context, AboutRepository about) => showAboutDialog(
|
||||||
create: (context) => AboutBloc(about)..add(const AboutEventStarted()),
|
create: (context) => AboutBloc(about)..add(const AboutEventStarted()),
|
||||||
child: const _SourceCode(),
|
child: const _SourceCode(),
|
||||||
),
|
),
|
||||||
|
const _Dictionaries(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Whose work the instance's dictionaries are, each credit with its
|
||||||
|
/// licence and where it is described; nothing without dictionaries, or
|
||||||
|
/// without the instance.
|
||||||
|
class const _Dictionaries() extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_Dictionaries> createState() => _DictionariesState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DictionariesState() extends State<_Dictionaries> {
|
||||||
|
late final Future<List<Dictionary>> _dictionaries = _read();
|
||||||
|
|
||||||
|
Future<List<Dictionary>> _read() async {
|
||||||
|
try {
|
||||||
|
return await DependenciesScope.of(context).dictionaries.dictionaries();
|
||||||
|
} on Object {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => FutureBuilder<List<Dictionary>>(
|
||||||
|
future: _dictionaries,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final credits = {
|
||||||
|
for (final dictionary in snapshot.data ?? const <Dictionary>[])
|
||||||
|
...dictionary.credits,
|
||||||
|
};
|
||||||
|
if (credits.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(AppLocalizations.of(context).aboutDictionaries),
|
||||||
|
for (final credit in credits) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${credit.name}: ${credit.by}, ${credit.license}',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
SelectableText(credit.url, style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class const _SourceCode() extends StatelessWidget {
|
class const _SourceCode() extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
// text), `report` ({category, note, at}: what is wrong, a note, when in
|
// text), `report` ({category, note, at}: what is wrong, a note, when in
|
||||||
// UTC; a category this app doesn't know reads as other) and a word's
|
// UTC; a category this app doesn't know reads as other) and a word's
|
||||||
// `senses` ([{glosses, label, pos, misc, field, dialect}], the codes as
|
// `senses` ([{glosses, label, pos, misc, field, dialect}], the codes as
|
||||||
// dictionaries write them).
|
// 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.
|
||||||
|
|
||||||
/// The schema this app writes.
|
/// The schema this app writes.
|
||||||
const cardSchemaVersion = 5;
|
const cardSchemaVersion = 6;
|
||||||
|
|
||||||
/// 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.
|
||||||
|
|
@ -64,6 +65,8 @@ ByteData encodeCardPayload(CardContent content, {String? origin}) =>
|
||||||
if (report.note.isNotEmpty) 'note': report.note,
|
if (report.note.isNotEmpty) 'note': report.note,
|
||||||
'at': report.at.toUtc().toIso8601String(),
|
'at': report.at.toUtc().toIso8601String(),
|
||||||
},
|
},
|
||||||
|
if (content.entry case (:final source, :final id))
|
||||||
|
'dictionary': {'source': source, 'id': id},
|
||||||
if (content.senses.isNotEmpty)
|
if (content.senses.isNotEmpty)
|
||||||
'senses': [
|
'senses': [
|
||||||
for (final sense in content.senses)
|
for (final sense in content.senses)
|
||||||
|
|
@ -130,6 +133,14 @@ CardContent decodeCardPayload(ByteData payload) {
|
||||||
senses: [
|
senses: [
|
||||||
for (final sense in _list(json['senses'], 'senses')) _sense(sense),
|
for (final sense in _list(json['senses'], 'senses')) _sense(sense),
|
||||||
],
|
],
|
||||||
|
entry: switch (json['dictionary']) {
|
||||||
|
null => null,
|
||||||
|
{'source': final String source, 'id': final String id} => (
|
||||||
|
source: source,
|
||||||
|
id: id,
|
||||||
|
),
|
||||||
|
_ => throw const FormatException('A card has a bad dictionary entry.'),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw const FormatException('Not a card payload.');
|
throw const FormatException('Not a card payload.');
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,9 @@ enum CardField() {
|
||||||
|
|
||||||
/// A word's senses.
|
/// A word's senses.
|
||||||
senses,
|
senses,
|
||||||
|
|
||||||
|
/// The dictionary entry a word was made from.
|
||||||
|
entry,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a report says is wrong with a card.
|
/// What a report says is wrong with a card.
|
||||||
|
|
@ -176,6 +179,11 @@ final class const Matching({
|
||||||
List<Object?> get props => [targets, items];
|
List<Object?> get props => [targets, items];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A dictionary entry for good: the dictionary's source (`jmdict`) and the
|
||||||
|
/// entry's id in it. A card made from one keeps it to be found again, and not
|
||||||
|
/// to be added twice; a soft reference, as the dictionary may change.
|
||||||
|
typedef DictionaryRef = ({String source, String id});
|
||||||
|
|
||||||
/// What a card holds: its [type] and the fields of that type. Fields of
|
/// What a card holds: its [type] and the fields of that type. Fields of
|
||||||
/// other types stay empty.
|
/// other types stay empty.
|
||||||
final class const CardContent({
|
final class const CardContent({
|
||||||
|
|
@ -218,6 +226,10 @@ final class const CardContent({
|
||||||
/// A word's senses, from a dictionary; empty for a word of meanings
|
/// A word's senses, from a dictionary; empty for a word of meanings
|
||||||
/// alone. With senses, its [meanings] are their glosses.
|
/// alone. With senses, its [meanings] are their glosses.
|
||||||
final List<VocabularySense> senses = const [],
|
final List<VocabularySense> senses = const [],
|
||||||
|
|
||||||
|
/// The dictionary entry it was made from; kept whatever its type, as the
|
||||||
|
/// page of a PDF is.
|
||||||
|
final DictionaryRef? entry,
|
||||||
}) 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() =>
|
||||||
|
|
@ -241,6 +253,7 @@ final class const CardContent({
|
||||||
CardField.explanation => explanation,
|
CardField.explanation => explanation,
|
||||||
CardField.report => report,
|
CardField.report => report,
|
||||||
CardField.senses => senses,
|
CardField.senses => senses,
|
||||||
|
CardField.entry => entry,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -262,6 +275,7 @@ final class const CardContent({
|
||||||
senses: field == CardField.senses
|
senses: field == CardField.senses
|
||||||
? value! as List<VocabularySense>
|
? value! as List<VocabularySense>
|
||||||
: senses,
|
: senses,
|
||||||
|
entry: field == CardField.entry ? value as DictionaryRef? : entry,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// This content with some fields replaced; [withField] clears the map, the
|
/// This content with some fields replaced; [withField] clears the map, the
|
||||||
|
|
@ -278,6 +292,7 @@ final class const CardContent({
|
||||||
PdfPageRef? source,
|
PdfPageRef? source,
|
||||||
List<RichContent>? hints,
|
List<RichContent>? hints,
|
||||||
List<VocabularySense>? senses,
|
List<VocabularySense>? senses,
|
||||||
|
DictionaryRef? entry,
|
||||||
}) => CardContent(
|
}) => CardContent(
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
front: front ?? this.front,
|
front: front ?? this.front,
|
||||||
|
|
@ -292,6 +307,7 @@ final class const CardContent({
|
||||||
explanation: explanation,
|
explanation: explanation,
|
||||||
report: report,
|
report: report,
|
||||||
senses: senses ?? this.senses,
|
senses: senses ?? this.senses,
|
||||||
|
entry: entry ?? this.entry,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 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
|
||||||
|
|
@ -336,6 +352,7 @@ final class const CardContent({
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
report: report,
|
report: report,
|
||||||
|
entry: entry,
|
||||||
);
|
);
|
||||||
for (final field in kept) {
|
for (final field in kept) {
|
||||||
content = content.withField(field, this[field]);
|
content = content.withField(field, this[field]);
|
||||||
|
|
@ -443,6 +460,7 @@ final class const CardContent({
|
||||||
explanation,
|
explanation,
|
||||||
report,
|
report,
|
||||||
senses,
|
senses,
|
||||||
|
entry,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_face
|
||||||
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';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_lookup_sheet.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.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/model/geo_map.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/model/geo_map.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
import 'package:halkyon_learn_flutter/src/feature/geo_map/widget/map_of.dart';
|
||||||
|
|
@ -137,6 +140,31 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Looks the word on the front up in [dictionary], and writes the card
|
||||||
|
/// with the word chosen: its headword, reading and senses, and where it
|
||||||
|
/// is from. Hints, an explanation and the tags stay.
|
||||||
|
Future<void> _lookUp(Dictionary dictionary) async {
|
||||||
|
final word = await lookUpWord(
|
||||||
|
context,
|
||||||
|
dictionary,
|
||||||
|
query: _sent.front.plainText.trim(),
|
||||||
|
);
|
||||||
|
if (word == null || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final filled = _sent
|
||||||
|
.copyWith(
|
||||||
|
front: RichContent.plain(word.headword),
|
||||||
|
reading: word.reading ?? '',
|
||||||
|
senses: word.senses,
|
||||||
|
entry: word.ref,
|
||||||
|
)
|
||||||
|
.normalized;
|
||||||
|
// The fields start afresh with it, as with content from elsewhere.
|
||||||
|
setState(() => _revision++);
|
||||||
|
_change(filled);
|
||||||
|
}
|
||||||
|
|
||||||
void _send(CardComposerEvent event) =>
|
void _send(CardComposerEvent event) =>
|
||||||
context.read<CardComposerBloc>().add(event);
|
context.read<CardComposerBloc>().add(event);
|
||||||
|
|
||||||
|
|
@ -305,6 +333,25 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
|
||||||
onPinPressed: () => _send(
|
onPinPressed: () => _send(
|
||||||
const CardComposerEventPinToggled(CardPin.front),
|
const CardComposerEventPinToggled(CardPin.front),
|
||||||
),
|
),
|
||||||
|
// A word, from the dictionary of the studyset's
|
||||||
|
// language where the instance has one.
|
||||||
|
action: kind == CardKind.vocabulary
|
||||||
|
? StudysetDictionary(
|
||||||
|
builder: (context, dictionary) =>
|
||||||
|
switch (dictionary) {
|
||||||
|
null => const SizedBox.shrink(),
|
||||||
|
final dictionary => IconButton(
|
||||||
|
tooltip: l10n.dictionaryLookUp,
|
||||||
|
icon: const Icon(
|
||||||
|
LucideIcons.bookSearch,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () => _lookUp(dictionary),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
CardFace(
|
CardFace(
|
||||||
|
|
@ -640,6 +687,7 @@ class const _Conflicts({required final CardDraft draft})
|
||||||
CardField.explanation => l10n.cardExplanation,
|
CardField.explanation => l10n.cardExplanation,
|
||||||
CardField.report => l10n.cardReport,
|
CardField.report => l10n.cardReport,
|
||||||
CardField.senses => l10n.cardSenses,
|
CardField.senses => l10n.cardSenses,
|
||||||
|
CardField.entry => l10n.cardDictionaryEntry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -684,6 +732,7 @@ class const _Version({required final Object? version}) extends StatelessWidget {
|
||||||
(pdfId: _, page: final int page) => Text(
|
(pdfId: _, page: final int page) => Text(
|
||||||
AppLocalizations.of(context).pdfPageShort(page),
|
AppLocalizations.of(context).pdfPageShort(page),
|
||||||
),
|
),
|
||||||
|
(source: final String source, id: final String id) => Text('$source $id'),
|
||||||
_ => Text('${version ?? ''}'),
|
_ => Text('${version ?? ''}'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ class const CardFieldLabel({
|
||||||
|
|
||||||
/// Pins or unpins it.
|
/// Pins or unpins it.
|
||||||
required final VoidCallback onPinPressed,
|
required final VoidCallback onPinPressed,
|
||||||
|
|
||||||
|
/// What the field offers beside its pin, e.g. looking a word up.
|
||||||
|
final Widget? action,
|
||||||
super.key,
|
super.key,
|
||||||
}) extends StatelessWidget {
|
}) extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -24,6 +27,7 @@ class const CardFieldLabel({
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(label, style: theme.textTheme.labelLarge)),
|
Expanded(child: Text(label, style: theme.textTheme.labelLarge)),
|
||||||
|
?action,
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onPinPressed,
|
onPressed: onPinPressed,
|
||||||
tooltip: pinned ? l10n.composerUnpin(label) : l10n.composerPin(label),
|
tooltip: pinned ? l10n.composerUnpin(label) : l10n.composerPin(label),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
|
||||||
|
/// The labels a whole level is added by, in the order offered: for
|
||||||
|
/// Japanese the JLPT levels, easiest first, then the other common words.
|
||||||
|
List<String> levelLabelsOf(Dictionary dictionary) => switch (dictionary) {
|
||||||
|
Dictionary(language: 'ja') => const [
|
||||||
|
'jlpt:n5',
|
||||||
|
'jlpt:n4',
|
||||||
|
'jlpt:n3',
|
||||||
|
'jlpt:n2',
|
||||||
|
'jlpt:n1',
|
||||||
|
'common',
|
||||||
|
],
|
||||||
|
_ => const ['common'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Looking words up in [_dictionary] and adding them to the studyset
|
||||||
|
/// [_studysetId] as flashcards: one by one from a search, or a whole level
|
||||||
|
/// at once. Knows which words the studyset holds already.
|
||||||
|
class DictionaryBloc(
|
||||||
|
final DictionaryRepository _dictionaries,
|
||||||
|
final DictionaryAdder _adder,
|
||||||
|
final Dictionary _dictionary,
|
||||||
|
final String _studysetId,
|
||||||
|
) extends Bloc<DictionaryEvent, DictionaryState> {
|
||||||
|
this : super(const DictionaryState()) {
|
||||||
|
on<DictionaryEventStarted>(_onStarted);
|
||||||
|
on<DictionaryEventQueryChanged>(_onQueryChanged);
|
||||||
|
on<DictionaryEventWordAdded>(_onWordAdded);
|
||||||
|
on<DictionaryEventLevelChosen>(
|
||||||
|
(event, emit) => emit(state.copyWith(level: event.label)),
|
||||||
|
);
|
||||||
|
on<DictionaryEventLevelAdded>(_onLevelAdded);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStarted(
|
||||||
|
DictionaryEventStarted event,
|
||||||
|
Emitter<DictionaryState> emit,
|
||||||
|
) async {
|
||||||
|
emit(state.copyWith(inStudyset: await _adder.entriesIn(_studysetId)));
|
||||||
|
try {
|
||||||
|
final labels = levelLabelsOf(_dictionary);
|
||||||
|
final counts = {
|
||||||
|
for (final label in labels)
|
||||||
|
label: await _dictionaries.countLabelled(_dictionary, label),
|
||||||
|
};
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
levelCounts: counts,
|
||||||
|
level: labels.firstWhere(
|
||||||
|
(label) => counts[label]! > 0,
|
||||||
|
orElse: () => labels.first,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Counting levels failed', name: 'Dictionary', error: error);
|
||||||
|
emit(state.copyWith(unreachable: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onQueryChanged(
|
||||||
|
DictionaryEventQueryChanged event,
|
||||||
|
Emitter<DictionaryState> emit,
|
||||||
|
) async {
|
||||||
|
final query = event.query.trim();
|
||||||
|
emit(state.copyWith(query: query, searching: query.isNotEmpty));
|
||||||
|
if (query.isEmpty) {
|
||||||
|
emit(state.copyWith(results: const []));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final results = await _dictionaries.search(_dictionary, query);
|
||||||
|
// A later query's results win, whichever comes back first.
|
||||||
|
if (state.query == query) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
results: results,
|
||||||
|
searching: false,
|
||||||
|
unreachable: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Searching failed', name: 'Dictionary', error: error);
|
||||||
|
if (state.query == query) {
|
||||||
|
emit(state.copyWith(searching: false, unreachable: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onWordAdded(
|
||||||
|
DictionaryEventWordAdded event,
|
||||||
|
Emitter<DictionaryState> emit,
|
||||||
|
) async {
|
||||||
|
final added = await _adder.add(_studysetId, [event.word]);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
inStudyset: {...state.inStudyset, event.word.ref},
|
||||||
|
outcome: (added: added, word: event.word.headword),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLevelAdded(
|
||||||
|
DictionaryEventLevelAdded event,
|
||||||
|
Emitter<DictionaryState> emit,
|
||||||
|
) async {
|
||||||
|
final label = state.level;
|
||||||
|
final count = state.levelCounts[label];
|
||||||
|
if (label == null || count == null || state.adding != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(adding: (done: 0, count: count)));
|
||||||
|
try {
|
||||||
|
final words = await _dictionaries.allLabelled(
|
||||||
|
_dictionary,
|
||||||
|
label,
|
||||||
|
onProgress: (done) =>
|
||||||
|
emit(state.copyWith(adding: (done: done, count: count))),
|
||||||
|
);
|
||||||
|
final added = await _adder.add(_studysetId, words);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
inStudyset: await _adder.entriesIn(_studysetId),
|
||||||
|
outcome: (added: added, word: null),
|
||||||
|
adding: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Adding a level failed', name: 'Dictionary', error: error);
|
||||||
|
emit(state.copyWith(adding: null, unreachable: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What adding words came to, and the word when it was one.
|
||||||
|
typedef DictionaryOutcome = ({DictionaryAdded added, String? word});
|
||||||
|
|
||||||
|
/// The dictionary screen: the search and what it found, which words the
|
||||||
|
/// studyset holds, the levels and how many words each has, the level
|
||||||
|
/// chosen, how far adding one is, and what the last adding came to.
|
||||||
|
final class const DictionaryState({
|
||||||
|
final String query = '',
|
||||||
|
final List<DictionaryWord> results = const [],
|
||||||
|
final bool searching = false,
|
||||||
|
final bool unreachable = false,
|
||||||
|
final Set<DictionaryRef> inStudyset = const {},
|
||||||
|
final Map<String, int> levelCounts = const {},
|
||||||
|
final String? level,
|
||||||
|
final ({int done, int count})? adding,
|
||||||
|
final DictionaryOutcome? outcome,
|
||||||
|
}) with Equatable {
|
||||||
|
/// This state with some fields replaced; [adding] and [level] can be
|
||||||
|
/// cleared by passing null on purpose, [outcome] is replaced each time.
|
||||||
|
DictionaryState copyWith({
|
||||||
|
String? query,
|
||||||
|
List<DictionaryWord>? results,
|
||||||
|
bool? searching,
|
||||||
|
bool? unreachable,
|
||||||
|
Set<DictionaryRef>? inStudyset,
|
||||||
|
Map<String, int>? levelCounts,
|
||||||
|
String? level,
|
||||||
|
Object? adding = _keep,
|
||||||
|
DictionaryOutcome? outcome,
|
||||||
|
}) => DictionaryState(
|
||||||
|
query: query ?? this.query,
|
||||||
|
results: results ?? this.results,
|
||||||
|
searching: searching ?? this.searching,
|
||||||
|
unreachable: unreachable ?? this.unreachable,
|
||||||
|
inStudyset: inStudyset ?? this.inStudyset,
|
||||||
|
levelCounts: levelCounts ?? this.levelCounts,
|
||||||
|
level: level ?? this.level,
|
||||||
|
adding: identical(adding, _keep)
|
||||||
|
? this.adding
|
||||||
|
: adding as ({int done, int count})?,
|
||||||
|
outcome: outcome,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
searching,
|
||||||
|
unreachable,
|
||||||
|
inStudyset,
|
||||||
|
levelCounts,
|
||||||
|
level,
|
||||||
|
adding,
|
||||||
|
outcome,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const _keep = Object();
|
||||||
|
|
||||||
|
/// What happens on the dictionary screen.
|
||||||
|
sealed class const DictionaryEvent() with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The screen opens: which words the studyset holds, how many each level
|
||||||
|
/// has.
|
||||||
|
final class const DictionaryEventStarted() extends DictionaryEvent;
|
||||||
|
|
||||||
|
/// The person searches for [query].
|
||||||
|
final class const DictionaryEventQueryChanged(final String query)
|
||||||
|
extends DictionaryEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [query];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person adds [word] to the studyset.
|
||||||
|
final class const DictionaryEventWordAdded(final DictionaryWord word)
|
||||||
|
extends DictionaryEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [word];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person chooses the level [label] (`jlpt:n5`).
|
||||||
|
final class const DictionaryEventLevelChosen(final String label)
|
||||||
|
extends DictionaryEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [label];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person adds every word of the level chosen.
|
||||||
|
final class const DictionaryEventLevelAdded() extends DictionaryEvent;
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
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/dictionary/model/dictionary.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
|
|
||||||
|
/// What adding words came to: how many became cards, and how many the
|
||||||
|
/// studyset held already.
|
||||||
|
typedef DictionaryAdded = ({int added, int skipped});
|
||||||
|
|
||||||
|
/// Adds dictionary words to a studyset as vocabulary cards, each once: a
|
||||||
|
/// word a card of the studyset was made from already is left out. Each card
|
||||||
|
/// is tagged with the word's JLPT level (`jlpt-n5`) and its word class
|
||||||
|
/// (`verb`), by its first sense, so a level or a class can be studied alone;
|
||||||
|
/// tags are lower case, as tag names are kept.
|
||||||
|
class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
||||||
|
/// The dictionary entries the cards of [studysetId] were made from.
|
||||||
|
Future<Set<DictionaryRef>> entriesIn(String studysetId) async => {
|
||||||
|
for (final card in await _cards.cardsOf(studysetId)) ?card.content.entry,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Adds [words] to [studysetId], at its end, in their order.
|
||||||
|
Future<DictionaryAdded> add(
|
||||||
|
String studysetId,
|
||||||
|
List<DictionaryWord> words,
|
||||||
|
) async {
|
||||||
|
final there = await entriesIn(studysetId);
|
||||||
|
final fresh = [
|
||||||
|
for (final word in words)
|
||||||
|
if (there.add(word.ref)) word,
|
||||||
|
];
|
||||||
|
if (fresh.isEmpty) {
|
||||||
|
return (added: 0, skipped: words.length);
|
||||||
|
}
|
||||||
|
final ids = await _cards.createAll(studysetId, [
|
||||||
|
for (final word in fresh) word.card,
|
||||||
|
], origin: fresh.first.ref.source);
|
||||||
|
|
||||||
|
final tagIdOf = <String, String>{};
|
||||||
|
final tagsByCard = <String, Set<String>>{};
|
||||||
|
for (final (index, word) in fresh.indexed) {
|
||||||
|
for (final name in tagNamesOf(word)) {
|
||||||
|
final tagId = tagIdOf[name] ??= await _tags.create(name);
|
||||||
|
(tagsByCard[ids[index]] ??= {}).add(tagId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _tags.addTags(tagsByCard);
|
||||||
|
return (added: fresh.length, skipped: words.length - fresh.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tags a card made from [word] gets: its JLPT level and its word class
|
||||||
|
/// by its first sense (JMdict's codes, grouped), in English as the
|
||||||
|
/// dictionary's codes are.
|
||||||
|
List<String> tagNamesOf(DictionaryWord word) => [
|
||||||
|
if (word.jlpt case final level?) 'jlpt-n$level',
|
||||||
|
...{
|
||||||
|
for (final code in word.senses.firstOrNull?.pos ?? const <String>[])
|
||||||
|
?_wordClass(code),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
String? _wordClass(String code) => switch (code) {
|
||||||
|
'n' || 'n-adv' || 'n-t' || 'n-pr' => 'noun',
|
||||||
|
'pn' => 'pronoun',
|
||||||
|
'adj-i' || 'adj-ix' => 'i-adjective',
|
||||||
|
'adj-na' => 'na-adjective',
|
||||||
|
'adv' || 'adv-to' => 'adverb',
|
||||||
|
'prt' => 'particle',
|
||||||
|
'conj' => 'conjunction',
|
||||||
|
'int' => 'interjection',
|
||||||
|
'ctr' => 'counter',
|
||||||
|
'exp' => 'expression',
|
||||||
|
_ when code.startsWith('v') && code != 'vi' && code != 'vt' => 'verb',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
|
||||||
|
/// The boundary to the instance's dictionaries. Tests replace it with a
|
||||||
|
/// fake.
|
||||||
|
abstract interface class DictionaryGateway() {
|
||||||
|
/// The dictionaries the instance holds.
|
||||||
|
Future<List<Dictionary>> dictionaries();
|
||||||
|
|
||||||
|
/// The words of [dictionary] that [query] finds, best first.
|
||||||
|
Future<List<DictionaryWord>> search(Dictionary dictionary, String query);
|
||||||
|
|
||||||
|
/// How many words of [dictionary] carry [label].
|
||||||
|
Future<int> countLabelled(Dictionary dictionary, String label);
|
||||||
|
|
||||||
|
/// The words of [dictionary] that carry [label], in its order: [limit] of
|
||||||
|
/// them after the first [offset].
|
||||||
|
Future<List<DictionaryWord>> labelled(
|
||||||
|
Dictionary dictionary,
|
||||||
|
String label, {
|
||||||
|
required int offset,
|
||||||
|
required int limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||||
|
|
||||||
|
/// The instance's dictionaries: which there are, words looked up in one,
|
||||||
|
/// and all its words with a label. Reference data on the server, so it needs
|
||||||
|
/// the instance to be reachable; the cards made from it don't.
|
||||||
|
class DictionaryRepository(final DictionaryGateway _gateway) {
|
||||||
|
/// The dictionaries as last asked; asked again after a failure.
|
||||||
|
List<Dictionary>? _known;
|
||||||
|
|
||||||
|
/// How many words one request takes, when all words with a label come.
|
||||||
|
static const page = 500;
|
||||||
|
|
||||||
|
/// The dictionaries the instance holds, asked once.
|
||||||
|
Future<List<Dictionary>> dictionaries() async =>
|
||||||
|
_known ??= await _gateway.dictionaries();
|
||||||
|
|
||||||
|
/// The dictionary for a studyset in [languages]: of its front's
|
||||||
|
/// language, with meanings in its back's, else in English, else in any;
|
||||||
|
/// null when there is none, or no front language.
|
||||||
|
Future<Dictionary?> dictionaryFor(StudysetLanguages languages) async {
|
||||||
|
final front = languages.front;
|
||||||
|
if (front == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final ofLanguage = [
|
||||||
|
for (final dictionary in await dictionaries())
|
||||||
|
if (dictionary.language == front) dictionary,
|
||||||
|
];
|
||||||
|
return ofLanguage
|
||||||
|
.where((dictionary) => dictionary.glossLanguage == languages.back)
|
||||||
|
.firstOrNull ??
|
||||||
|
ofLanguage
|
||||||
|
.where((dictionary) => dictionary.glossLanguage == 'en')
|
||||||
|
.firstOrNull ??
|
||||||
|
ofLanguage.firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The words of [dictionary] that [query] finds, best first.
|
||||||
|
Future<List<DictionaryWord>> search(Dictionary dictionary, String query) =>
|
||||||
|
query.trim().isEmpty
|
||||||
|
? Future.value(const [])
|
||||||
|
: _gateway.search(dictionary, query);
|
||||||
|
|
||||||
|
/// How many words of [dictionary] carry [label] (`jlpt:n5`).
|
||||||
|
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
||||||
|
_gateway.countLabelled(dictionary, label);
|
||||||
|
|
||||||
|
/// Every word of [dictionary] that carries [label], in its order, a page
|
||||||
|
/// at a time; [onProgress] hears how many have come so far.
|
||||||
|
Future<List<DictionaryWord>> allLabelled(
|
||||||
|
Dictionary dictionary,
|
||||||
|
String label, {
|
||||||
|
void Function(int count)? onProgress,
|
||||||
|
}) async {
|
||||||
|
final words = <DictionaryWord>[];
|
||||||
|
while (true) {
|
||||||
|
final next = await _gateway.labelled(
|
||||||
|
dictionary,
|
||||||
|
label,
|
||||||
|
offset: words.length,
|
||||||
|
limit: page,
|
||||||
|
);
|
||||||
|
words.addAll(next);
|
||||||
|
onProgress?.call(words.length);
|
||||||
|
if (next.length < page) {
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||||
|
|
||||||
|
/// [DictionaryGateway] asking the instance through the Serverpod client.
|
||||||
|
class ServerpodDictionaryGateway(final protocol.Client _client)
|
||||||
|
implements DictionaryGateway {
|
||||||
|
@override
|
||||||
|
Future<List<Dictionary>> dictionaries() async => [
|
||||||
|
for (final source in await _client.dictionary.dictionaries())
|
||||||
|
Dictionary(
|
||||||
|
source: source.source,
|
||||||
|
sourceVersion: source.sourceVersion,
|
||||||
|
language: source.language,
|
||||||
|
glossLanguage: source.glossLanguage,
|
||||||
|
license: source.license,
|
||||||
|
credits: [
|
||||||
|
for (final credit in source.credits)
|
||||||
|
(
|
||||||
|
name: credit.name,
|
||||||
|
by: credit.by,
|
||||||
|
license: credit.license,
|
||||||
|
url: credit.url,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
entryCount: source.entryCount,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DictionaryWord>> search(
|
||||||
|
Dictionary dictionary,
|
||||||
|
String query,
|
||||||
|
) async => [
|
||||||
|
for (final hit in await _client.dictionary.search(
|
||||||
|
dictionary.language,
|
||||||
|
dictionary.glossLanguage,
|
||||||
|
query,
|
||||||
|
))
|
||||||
|
_word(hit),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
||||||
|
_client.dictionary.countLabelled(
|
||||||
|
dictionary.language,
|
||||||
|
dictionary.glossLanguage,
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<DictionaryWord>> labelled(
|
||||||
|
Dictionary dictionary,
|
||||||
|
String label, {
|
||||||
|
required int offset,
|
||||||
|
required int limit,
|
||||||
|
}) async => [
|
||||||
|
for (final hit in await _client.dictionary.labelled(
|
||||||
|
dictionary.language,
|
||||||
|
dictionary.glossLanguage,
|
||||||
|
label,
|
||||||
|
offset: offset,
|
||||||
|
limit: limit,
|
||||||
|
))
|
||||||
|
_word(hit),
|
||||||
|
];
|
||||||
|
|
||||||
|
DictionaryWord _word(protocol.DictionaryHit hit) => DictionaryWord.fromEntry(
|
||||||
|
ref: (source: hit.source, id: hit.sourceId),
|
||||||
|
headword: hit.headword,
|
||||||
|
reading: hit.reading,
|
||||||
|
labels: hit.labels,
|
||||||
|
entry: hit.entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
|
||||||
|
/// Who made what a dictionary is drawn from, under which licence.
|
||||||
|
typedef DictionaryCredit = ({
|
||||||
|
String name,
|
||||||
|
String by,
|
||||||
|
String license,
|
||||||
|
String url,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A dictionary the instance holds: words of [language] with meanings in
|
||||||
|
/// [glossLanguage] (BCP 47 tags), from [source] at [sourceVersion].
|
||||||
|
final class const Dictionary({
|
||||||
|
required final String source,
|
||||||
|
required final String sourceVersion,
|
||||||
|
required final String language,
|
||||||
|
required final String glossLanguage,
|
||||||
|
required final String license,
|
||||||
|
required final List<DictionaryCredit> credits,
|
||||||
|
required final int entryCount,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
source,
|
||||||
|
sourceVersion,
|
||||||
|
language,
|
||||||
|
glossLanguage,
|
||||||
|
license,
|
||||||
|
credits,
|
||||||
|
entryCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A word of a dictionary: what it shows and turns into a card.
|
||||||
|
final class const DictionaryWord({
|
||||||
|
/// The dictionary entry it is, for good: a card keeps it.
|
||||||
|
required final DictionaryRef ref,
|
||||||
|
required final String headword,
|
||||||
|
|
||||||
|
/// How it is read; null when the headword is its own reading.
|
||||||
|
required final String? reading,
|
||||||
|
|
||||||
|
/// `common`, `jlpt:n5`, …
|
||||||
|
required final List<String> labels,
|
||||||
|
required final List<VocabularySense> senses,
|
||||||
|
}) with Equatable {
|
||||||
|
/// The word from its [entry] as the dictionary file holds it (JSON).
|
||||||
|
factory fromEntry({
|
||||||
|
required DictionaryRef ref,
|
||||||
|
required String headword,
|
||||||
|
required String? reading,
|
||||||
|
required List<String> labels,
|
||||||
|
required String entry,
|
||||||
|
}) {
|
||||||
|
final json = jsonDecode(entry);
|
||||||
|
final senses = json is Map<String, Object?> ? json['senses'] : null;
|
||||||
|
List<String> strings(Object? value) => [
|
||||||
|
if (value is List)
|
||||||
|
for (final item in value) item.toString(),
|
||||||
|
];
|
||||||
|
return DictionaryWord(
|
||||||
|
ref: ref,
|
||||||
|
headword: headword,
|
||||||
|
reading: reading,
|
||||||
|
labels: labels,
|
||||||
|
senses: [
|
||||||
|
if (senses is List)
|
||||||
|
for (final sense in senses.whereType<Map<String, Object?>>())
|
||||||
|
VocabularySense(
|
||||||
|
glosses: strings(sense['glosses']),
|
||||||
|
pos: strings(sense['pos']),
|
||||||
|
misc: strings(sense['misc']),
|
||||||
|
field: strings(sense['field']),
|
||||||
|
dialect: strings(sense['dialect']),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Its JLPT level, 5 for N5 to 1 for N1; null when it has none.
|
||||||
|
int? get jlpt => [
|
||||||
|
for (final label in labels)
|
||||||
|
if (RegExp(r'^jlpt:n([1-5])$').firstMatch(label) case final match?)
|
||||||
|
int.parse(match[1]!),
|
||||||
|
].firstOrNull;
|
||||||
|
|
||||||
|
/// The word as a vocabulary card: its headword on the front, its reading
|
||||||
|
/// (none for a word written in kana) and senses, and where it came from.
|
||||||
|
CardContent get card => CardContent(
|
||||||
|
type: CardType.vocabulary,
|
||||||
|
front: RichContent.plain(headword),
|
||||||
|
back: RichContent.empty(),
|
||||||
|
reading: reading ?? '',
|
||||||
|
senses: senses,
|
||||||
|
entry: ref,
|
||||||
|
).normalized;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [ref, headword, reading, labels, senses];
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue