feat(app): write kanji, kana and words by hand, each stroke checked

The study option Write by hand (settings schema 7) has character cards
and words written in a session, on a schedule of their own (review mode
`write`; older apps skip a mode they don't know): what to write on top, a
character's meanings and readings or a word's reading and meanings, and
below it a square to write it in, character by character.

Each stroke is checked as it ends against KanjiVG's: both resampled to 22
points, then their shape once centred, where they lie, how long they are
and which way they go, each against what a loose hand strays by; a stroke
closer to one still to come is out of order. One that counts is drawn as
KanjiVG draws it, and the hand's offset from the middle is forgiven in the
strokes after; any other flashes red, says why and counts as a mistake, as
does a hint. Two misses in a row show the next stroke faintly. Once
written, or given up on (Show it), the rating the mistakes suggest is
outlined: none Good, one or two OK, more Bad.

The square takes every touch on it at once, so writing never scrolls the
card or swipes to rate. Characters without a stroke order (in a word) are
written for the person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 15:59:40 +02:00
commit 89ad2c237d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
25 changed files with 1457 additions and 54 deletions

View file

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

View file

@ -61,7 +61,9 @@ revision's payload, and the per-side merge of ADR 0003 becomes per field.
heatmap, and never moves a schedule). A card's schedule replays only the reviews of
one mode. Existing reviews are `review`. Resetting a card's progress (added
2026-10-01) appends a `reset` entry rather than deleting reviews: schedules replay
only what follows it, and the streak and the heatmap keep the history.
only what follows it, and the streak and the heatmap keep the history. Writing a
character or a word by hand (added 2026-10-09) is `write`, with a schedule of its
own: recalling a kanji and writing it are learnt apart, as a reverse card is.
4. **A person's settings are one synced row.** `person_settings` holds settings in a
versioned payload: study goals now, other preferences later. Two devices creating it
while offline make two rows; the app keeps the one with the smallest id and folds the

View file

@ -93,6 +93,7 @@ abstract class ReviewLog
/// How the card was studied (ADR 0004): 'review' (front to back), 'reverse'
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
/// 'write' (written by hand, stroke by stroke, with a schedule of its own),
/// or 'cram' (outside the schedule: counts for streaks and the activity
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
/// 'reset' is no rating but a fresh start: the card's schedules replay only
@ -313,6 +314,7 @@ class ReviewLogTable extends _isd.Table<_isc.UuidValue?> {
/// How the card was studied (ADR 0004): 'review' (front to back), 'reverse'
/// (back to front, with a schedule of its own, as Anki's reverse cards have),
/// 'write' (written by hand, stroke by stroke, with a schedule of its own),
/// or 'cram' (outside the schedule: counts for streaks and the activity
/// heatmap, never moves a schedule). Reviews from before modes are 'review'.
/// 'reset' is no rating but a fresh start: the card's schedules replay only

View file

@ -541,6 +541,18 @@
"kanjiPlayAgain": "Noch einmal abspielen",
"kanjiNoStrokes": "Für dieses Schriftzeichen gibt es keine Strichfolge.",
"kanjiComponents": "Bestandteile",
"studySettingsCharacters": "Kanji & Kana",
"studySettingsWriteByHand": "Von Hand schreiben",
"studySettingsWriteByHandDescription": "Kanji, Kana und Wörter schreibst du Strich für Strich, jeder Strich wird gleich geprüft, nach einem eigenen Plan. Geschriebene Wörter werden weder abgefragt noch getippt.",
"writingHint": "Tipp",
"writingShow": "Zeigen",
"writingMistakes": "{count, plural, =0{Keine Fehler} =1{1 Fehler} other{{count} Fehler}}",
"@writingMistakes": {"placeholders": {"count": {"type": "int"}}},
"writingWrongOrder": "Noch nicht: Dieser Strich kommt später.",
"writingWrong": "Das ist nicht der nächste Strich.",
"writingSuggested": "{rating} vorgeschlagen",
"@writingSuggested": {"placeholders": {"rating": {"type": "String"}}},
"writingNoStrokes": "Für diese Schriftzeichen gibt es keine Strichfolge: Bewerte, wie gut du sie wusstest.",
"aboutDictionaries": "Wörterbücher dieser Instanz",
"cardReportTitle": "Fehler melden",
"cardReportSubtitle": "Markiere diese Karte, um sie später zu finden und zu korrigieren.",

View file

@ -597,6 +597,18 @@
"kanjiPlayAgain": "Play again",
"kanjiNoStrokes": "There's no stroke order for this character.",
"kanjiComponents": "Components",
"studySettingsCharacters": "Kanji & kana",
"studySettingsWriteByHand": "Write by hand",
"studySettingsWriteByHandDescription": "Kanji, kana and words are written stroke by stroke and checked as you go, on a schedule of their own. Words written aren't quizzed or typed.",
"writingHint": "Hint",
"writingShow": "Show it",
"writingMistakes": "{count, plural, =0{No mistakes} =1{1 mistake} other{{count} mistakes}}",
"@writingMistakes": {"placeholders": {"count": {"type": "int"}}},
"writingWrongOrder": "Not yet: that stroke comes later.",
"writingWrong": "That isn't the next stroke.",
"writingSuggested": "{rating} suggested",
"@writingSuggested": {"placeholders": {"rating": {"type": "String"}}},
"writingNoStrokes": "There's no stroke order for these characters: rate how well you knew them.",
"aboutDictionaries": "Dictionaries on this instance",
"cardReportTitle": "Report an error",
"cardReportSubtitle": "Flag this card so you can find and fix it later.",

View file

@ -3232,6 +3232,66 @@ abstract class AppLocalizations {
/// **'Components'**
String get kanjiComponents;
/// No description provided for @studySettingsCharacters.
///
/// In en, this message translates to:
/// **'Kanji & kana'**
String get studySettingsCharacters;
/// No description provided for @studySettingsWriteByHand.
///
/// In en, this message translates to:
/// **'Write by hand'**
String get studySettingsWriteByHand;
/// No description provided for @studySettingsWriteByHandDescription.
///
/// In en, this message translates to:
/// **'Kanji, kana and words are written stroke by stroke and checked as you go, on a schedule of their own. Words written aren\'t quizzed or typed.'**
String get studySettingsWriteByHandDescription;
/// No description provided for @writingHint.
///
/// In en, this message translates to:
/// **'Hint'**
String get writingHint;
/// No description provided for @writingShow.
///
/// In en, this message translates to:
/// **'Show it'**
String get writingShow;
/// No description provided for @writingMistakes.
///
/// In en, this message translates to:
/// **'{count, plural, =0{No mistakes} =1{1 mistake} other{{count} mistakes}}'**
String writingMistakes(int count);
/// No description provided for @writingWrongOrder.
///
/// In en, this message translates to:
/// **'Not yet: that stroke comes later.'**
String get writingWrongOrder;
/// No description provided for @writingWrong.
///
/// In en, this message translates to:
/// **'That isn\'t the next stroke.'**
String get writingWrong;
/// No description provided for @writingSuggested.
///
/// In en, this message translates to:
/// **'{rating} suggested'**
String writingSuggested(String rating);
/// No description provided for @writingNoStrokes.
///
/// In en, this message translates to:
/// **'There\'s no stroke order for these characters: rate how well you knew them.'**
String get writingNoStrokes;
/// No description provided for @aboutDictionaries.
///
/// In en, this message translates to:

View file

@ -1962,6 +1962,49 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get kanjiComponents => 'Bestandteile';
@override
String get studySettingsCharacters => 'Kanji & Kana';
@override
String get studySettingsWriteByHand => 'Von Hand schreiben';
@override
String get studySettingsWriteByHandDescription =>
'Kanji, Kana und Wörter schreibst du Strich für Strich, jeder Strich wird gleich geprüft, nach einem eigenen Plan. Geschriebene Wörter werden weder abgefragt noch getippt.';
@override
String get writingHint => 'Tipp';
@override
String get writingShow => 'Zeigen';
@override
String writingMistakes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Fehler',
one: '1 Fehler',
zero: 'Keine Fehler',
);
return '$_temp0';
}
@override
String get writingWrongOrder => 'Noch nicht: Dieser Strich kommt später.';
@override
String get writingWrong => 'Das ist nicht der nächste Strich.';
@override
String writingSuggested(String rating) {
return '$rating vorgeschlagen';
}
@override
String get writingNoStrokes =>
'Für diese Schriftzeichen gibt es keine Strichfolge: Bewerte, wie gut du sie wusstest.';
@override
String get aboutDictionaries => 'Wörterbücher dieser Instanz';

View file

@ -1950,6 +1950,49 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get kanjiComponents => 'Components';
@override
String get studySettingsCharacters => 'Kanji & kana';
@override
String get studySettingsWriteByHand => 'Write by hand';
@override
String get studySettingsWriteByHandDescription =>
'Kanji, kana and words are written stroke by stroke and checked as you go, on a schedule of their own. Words written aren\'t quizzed or typed.';
@override
String get writingHint => 'Hint';
@override
String get writingShow => 'Show it';
@override
String writingMistakes(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count mistakes',
one: '1 mistake',
zero: 'No mistakes',
);
return '$_temp0';
}
@override
String get writingWrongOrder => 'Not yet: that stroke comes later.';
@override
String get writingWrong => 'That isn\'t the next stroke.';
@override
String writingSuggested(String rating) {
return '$rating suggested';
}
@override
String get writingNoStrokes =>
'There\'s no stroke order for these characters: rate how well you knew them.';
@override
String get aboutDictionaries => 'Dictionaries on this instance';

View file

@ -0,0 +1,173 @@
import 'dart:math' as math;
import 'package:halkyon_learn_flutter/src/feature/kanji/model/character_strokes.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
/// How many points two strokes are compared at, each resampled to as many
/// evenly along its length.
const strokeSamples = 22;
/// The most a stroke written by hand may differ from the one it should be
/// ([strokeError]) and still count as it.
const strokeTolerance = 1.5;
/// How far the stroke written by hand ([written]) is from [expected], both
/// as points in KanjiVG's square in the order they were drawn: 0 for the
/// same stroke, about 1 for one written loosely but recognisably, more the
/// further off. Four things count, each against what a loose hand strays
/// by: its shape, compared point by point once both are centred; where it
/// lies; how long it is; and which way it goes, from its start to its end,
/// so even a short stroke written the other way round is far off (but a
/// dot, which goes no way to speak of).
double strokeError(List<StrokePoint> written, List<StrokePoint> expected) {
if (written.isEmpty || expected.isEmpty) {
return double.infinity;
}
final a = resampled(written, strokeSamples);
final b = resampled(expected, strokeSamples);
final centreA = _centre(a);
final centreB = _centre(b);
var shape = 0.0;
for (var i = 0; i < strokeSamples; i++) {
shape += _distance(
(x: a[i].x - centreA.x, y: a[i].y - centreA.y),
(x: b[i].x - centreB.x, y: b[i].y - centreB.y),
);
}
shape /= strokeSamples;
final position = _distance(centreA, centreB);
final expectedLength = lengthOf(expected);
final lengthOff = (lengthOf(written) - expectedLength).abs();
final way = (x: b.last.x - b.first.x, y: b.last.y - b.first.y);
final wayWritten = (x: a.last.x - a.first.x, y: a.last.y - a.first.y);
final turned = _distance(way, (x: 0, y: 0)) < _dot
? 0.0
: _angle(wayWritten, way);
return shape / 10 +
position / 15 +
lengthOff / math.max(20, expectedLength / 2) +
turned / (math.pi / 2) * 0.75;
}
/// How long a stroke's way from start to end is at least to count: a dot
/// goes no way to speak of.
const _dot = 8.0;
/// The angle between [a] and [b], from 0 to π; 0 for a vector of no
/// length.
double _angle(StrokePoint a, StrokePoint b) {
final lengths = _distance(a, (x: 0, y: 0)) * _distance(b, (x: 0, y: 0));
if (lengths == 0) {
return 0;
}
return math.acos(((a.x * b.x + a.y * b.y) / lengths).clamp(-1.0, 1.0));
}
/// What a stroke written by hand turned out to be.
enum StrokeVerdict() {
/// The stroke that comes next: it counts.
right,
/// A stroke still to come, written before its turn.
wrongOrder,
/// None of the strokes to come.
wrong,
}
/// Checks the stroke written by hand ([written]) against [strokes], the
/// character's strokes as points, those before [next] written already. It
/// is the next one when it is close enough to it ([strokeTolerance]) and
/// no closer to another stroke still to come; closer to one of those, it
/// was written out of order.
StrokeVerdict checkStroke(
List<StrokePoint> written,
List<List<StrokePoint>> strokes,
int next,
) {
final errors = [
for (var i = next; i < strokes.length; i++)
strokeError(written, strokes[i]),
];
final best = errors.reduce(math.min);
if (best > strokeTolerance) {
return StrokeVerdict.wrong;
}
return errors.first <= best ? StrokeVerdict.right : StrokeVerdict.wrongOrder;
}
/// How far [written] lies from [expected], from the middle of one to the
/// middle of the other: a hand writing the whole character off the middle
/// is off by about as much in every stroke, which the strokes after it are
/// forgiven.
StrokePoint strokeOffset(
List<StrokePoint> written,
List<StrokePoint> expected,
) {
final a = _centre(resampled(written, strokeSamples));
final b = _centre(resampled(expected, strokeSamples));
return (x: a.x - b.x, y: a.y - b.y);
}
/// The rating writing a card with [mistakes] suggests: none Good, one or
/// two Hard, more Again (decided 2026-10-09). Easy is the person's to give.
Rating suggestedRating(int mistakes) => switch (mistakes) {
0 => Rating.good,
<= 2 => Rating.hard,
_ => Rating.again,
};
/// [points] as [count] points evenly along the line through them, its ends
/// kept; all at the first for a line of no length.
List<StrokePoint> resampled(List<StrokePoint> points, int count) {
final total = lengthOf(points);
if (points.length < 2 || total == 0) {
return List.filled(count, points.first);
}
final step = total / (count - 1);
final result = [points.first];
var travelled = 0.0;
var target = step;
for (var i = 1; i < points.length && result.length < count - 1; i++) {
final from = points[i - 1];
final to = points[i];
final segment = _distance(from, to);
while (segment > 0 &&
travelled + segment >= target &&
result.length < count - 1) {
final t = (target - travelled) / segment;
result.add((
x: from.x + (to.x - from.x) * t,
y: from.y + (to.y - from.y) * t,
));
target += step;
}
travelled += segment;
}
while (result.length < count) {
result.add(points.last);
}
return result;
}
/// The length of the line through [points].
double lengthOf(List<StrokePoint> points) {
var length = 0.0;
for (var i = 1; i < points.length; i++) {
length += _distance(points[i - 1], points[i]);
}
return length;
}
StrokePoint _centre(List<StrokePoint> points) {
var x = 0.0;
var y = 0.0;
for (final point in points) {
x += point.x;
y += point.y;
}
return (x: x / points.length, y: y / points.length);
}
double _distance(StrokePoint a, StrokePoint b) =>
math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));

View file

@ -134,39 +134,12 @@ class const StrokesPainter({
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final scale = size.shortestSide / CharacterStrokes.box;
final guidePaint = Paint()
..color = guide
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(8)),
guidePaint,
);
_dashed(
canvas,
Offset(size.width / 2, 0),
Offset(size.width / 2, size.height),
guidePaint,
);
_dashed(
canvas,
Offset(0, size.height / 2),
Offset(size.width, size.height / 2),
guidePaint,
);
paintGuides(canvas, size, guide);
canvas
..save()
..scale(scale);
Paint stroke(Color color) => Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 4
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final faintPaint = stroke(faint);
final inkPaint = stroke(ink);
..scale(size.shortestSide / CharacterStrokes.box);
final faintPaint = strokePaint(faint);
final inkPaint = strokePaint(ink);
for (final path in paths) {
canvas.drawPath(path, faintPaint);
}
@ -191,7 +164,37 @@ class const StrokesPainter({
canvas.restore();
}
void _dashed(Canvas canvas, Offset from, Offset to, Paint paint) {
@override
bool shouldRepaint(StrokesPainter oldDelegate) =>
oldDelegate.progress != progress ||
!identical(oldDelegate.paths, paths) ||
!identical(oldDelegate.written, written) ||
oldDelegate.ink != ink ||
oldDelegate.faint != faint ||
oldDelegate.guide != guide;
}
/// How strokes are drawn in KanjiVG's square: [color], round at their ends
/// and turns.
Paint strokePaint(Color color) => Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 4
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
/// The square characters are written in, on [size]: its border and the
/// dashed lines through its middle, in [color].
void paintGuides(Canvas canvas, Size size, Color color) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1;
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(8)),
paint,
);
void dashed(Offset from, Offset to) {
const dash = 4.0;
final length = (to - from).distance;
final direction = (to - from) / length;
@ -204,12 +207,6 @@ class const StrokesPainter({
}
}
@override
bool shouldRepaint(StrokesPainter oldDelegate) =>
oldDelegate.progress != progress ||
!identical(oldDelegate.paths, paths) ||
!identical(oldDelegate.written, written) ||
oldDelegate.ink != ink ||
oldDelegate.faint != faint ||
oldDelegate.guide != guide;
dashed(Offset(size.width / 2, 0), Offset(size.width / 2, size.height));
dashed(Offset(0, size.height / 2), Offset(size.width, size.height / 2));
}

View file

@ -0,0 +1,252 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/character_strokes.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/stroke_check.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/stroke_order_view.dart';
import 'package:material_ui/material_ui.dart';
/// A square [size] wide to write [strokes]'s character on by hand, stroke
/// by stroke: each stroke is checked as it ends ([checkStroke]); one that
/// is the next counts and is drawn as KanjiVG draws it, any other flashes
/// and goes, and [onStroke] hears which it was. After two misses in a row
/// the next stroke shows faintly, as it does while [hinting]. Once every
/// stroke is written, [onWritten] is called; [revealed], the strokes not
/// written show faintly.
///
/// The square takes every touch on it at once, so writing never scrolls or
/// swipes what it is in.
class const WritingCanvas({
required final CharacterStrokes strokes,
required final ValueChanged<StrokeVerdict> onStroke,
required final VoidCallback onWritten,
final double size = 280,
final bool hinting = false,
final bool revealed = false,
super.key,
}) extends StatefulWidget {
/// How long a stroke that doesn't count stays to be seen.
static const flash = Duration(milliseconds: 500);
@override
State<WritingCanvas> createState() => _WritingCanvasState();
}
class _WritingCanvasState() extends State<WritingCanvas> {
late var _paths = _pathsOf(widget.strokes);
late var _lines = widget.strokes.polylines();
/// How many strokes are written.
var _done = 0;
/// The misses since the last stroke that counted.
var _misses = 0;
/// The stroke being written, in KanjiVG's square.
List<StrokePoint>? _writing;
/// The pointer writing it; one at a time.
int? _pointer;
/// The last stroke that didn't count, while it flashes.
List<StrokePoint>? _rejected;
Timer? _flash;
/// How far off the middle the hand writes, on average over the strokes
/// that counted: the strokes to come are checked as if moved back by it.
var _offset = (x: 0.0, y: 0.0);
static List<Path> _pathsOf(CharacterStrokes strokes) => [
for (final path in strokes.paths) strokePath(path),
];
@override
void didUpdateWidget(WritingCanvas oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.strokes != oldWidget.strokes) {
_paths = _pathsOf(widget.strokes);
_lines = widget.strokes.polylines();
_done = 0;
_misses = 0;
_writing = null;
_rejected = null;
_offset = (x: 0.0, y: 0.0);
}
}
@override
void dispose() {
_flash?.cancel();
super.dispose();
}
bool get _finished => _done >= _lines.length || widget.revealed;
StrokePoint _inBox(Offset local) => (
x: local.dx / widget.size * CharacterStrokes.box,
y: local.dy / widget.size * CharacterStrokes.box,
);
void _down(PointerDownEvent event) {
if (_pointer != null || _finished) {
return;
}
setState(() {
_pointer = event.pointer;
_writing = [_inBox(event.localPosition)];
});
}
void _move(PointerMoveEvent event) {
if (event.pointer != _pointer) {
return;
}
setState(() => _writing = [..._writing!, _inBox(event.localPosition)]);
}
void _up(PointerUpEvent event) {
if (event.pointer != _pointer) {
return;
}
final written = _writing!;
_pointer = null;
final moved = [
for (final (:x, :y) in written) (x: x - _offset.x, y: y - _offset.y),
];
final verdict = checkStroke(moved, _lines, _done);
setState(() {
_writing = null;
if (verdict == StrokeVerdict.right) {
final off = strokeOffset(written, _lines[_done]);
_offset = (
x: (_offset.x * _done + off.x) / (_done + 1),
y: (_offset.y * _done + off.y) / (_done + 1),
);
_done++;
_misses = 0;
} else {
_misses++;
_rejected = written;
_flash?.cancel();
_flash = Timer(WritingCanvas.flash, () {
if (mounted) {
setState(() => _rejected = null);
}
});
}
});
widget.onStroke(verdict);
if (verdict == StrokeVerdict.right && _done == _lines.length) {
widget.onWritten();
}
}
void _cancel(PointerCancelEvent event) {
if (event.pointer == _pointer) {
setState(() {
_pointer = null;
_writing = null;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return SizedBox.square(
dimension: widget.size,
child: RawGestureDetector(
gestures: {
EagerGestureRecognizer:
GestureRecognizerFactoryWithHandlers<EagerGestureRecognizer>(
EagerGestureRecognizer.new,
(_) {},
),
},
child: Listener(
onPointerDown: _down,
onPointerMove: _move,
onPointerUp: _up,
onPointerCancel: _cancel,
child: CustomPaint(
painter: _WritingPainter(
paths: _paths,
done: _done,
hint: !_finished && (widget.hinting || _misses >= 2)
? _done
: null,
revealed: widget.revealed,
writing: _writing,
rejected: _rejected,
ink: colors.onSurface,
hintColor: colors.primary.withValues(alpha: 0.35),
wrong: colors.error,
guide: colors.outlineVariant,
),
),
),
),
);
}
}
/// Paints writing in progress: the guides, the strokes written so far as
/// KanjiVG draws them, the [hint] stroke and, [revealed], those not written
/// faintly, the stroke being written, and the one that didn't count.
class const _WritingPainter({
required final List<Path> paths,
required final int done,
required final int? hint,
required final bool revealed,
required final List<StrokePoint>? writing,
required final List<StrokePoint>? rejected,
required final Color ink,
required final Color hintColor,
required final Color wrong,
required final Color guide,
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
paintGuides(canvas, size, guide);
canvas
..save()
..scale(size.shortestSide / CharacterStrokes.box);
final faint = strokePaint(hintColor);
for (final (i, path) in paths.indexed) {
if (i < done) {
canvas.drawPath(path, strokePaint(ink));
} else if (revealed || i == hint) {
canvas.drawPath(path, faint);
}
}
Path line(List<StrokePoint> points) {
final path = Path()..moveTo(points.first.x, points.first.y);
for (final (:x, :y) in points.skip(1)) {
path.lineTo(x, y);
}
return path;
}
if (rejected case final rejected? when rejected.isNotEmpty) {
canvas.drawPath(line(rejected), strokePaint(wrong));
}
if (writing case final writing? when writing.isNotEmpty) {
canvas.drawPath(line(writing), strokePaint(ink));
}
canvas.restore();
}
@override
bool shouldRepaint(_WritingPainter oldDelegate) =>
oldDelegate.done != done ||
oldDelegate.hint != hint ||
oldDelegate.revealed != revealed ||
!identical(oldDelegate.writing, writing) ||
!identical(oldDelegate.rejected, rejected) ||
!identical(oldDelegate.paths, paths) ||
oldDelegate.ink != ink ||
oldDelegate.hintColor != hintColor ||
oldDelegate.wrong != wrong ||
oldDelegate.guide != guide;
}

View file

@ -21,11 +21,12 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
// Schema 5 added quizzing vocabulary as multiple choice (`quizVocabulary`,
// `quizAspect` in `study`; an aspect this app doesn't know reads as the
// meaning). Schema 6 added typing the readings of vocabulary
// (`typeReadings` in `study`). Older schemas read with what they lack as it
// comes.
// (`typeReadings` in `study`). Schema 7 added writing characters and words
// by hand (`writeByHand` in `study`). Older schemas read with what they lack
// as it comes.
/// The schema this app writes.
const settingsSchemaVersion = 6;
const settingsSchemaVersion = 7;
/// Encodes [settings] into their payload.
ByteData encodeSettingsPayload(PersonSettings settings) {
@ -55,6 +56,8 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
'quizAspect': study.quizAspect.name,
if (study.typeReadings != defaults.typeReadings)
'typeReadings': study.typeReadings,
if (study.writeByHand != defaults.writeByHand)
'writeByHand': study.writeByHand,
},
if (settings.goal case final goal?)
'goal': {'period': goal.period.name, 'target': goal.target},
@ -157,6 +160,10 @@ StudyOptions _study(Object? json) {
final bool type => type,
_ => null,
},
writeByHand: switch (json['writeByHand']) {
final bool write => write,
_ => null,
},
);
}

View file

@ -401,29 +401,50 @@ class StudySessionBloc(
return {for (final id in ids) ...tagging.cardsWith(id)};
}
/// Whether [card] is written by hand in this session: a character, or
/// a word, while the person writes them.
bool _writes(Card card) =>
_options.writeByHand &&
(card.content.type == CardType.character ||
(card.content.type == CardType.vocabulary &&
!card.content.front.isBlank));
/// Whether [card] asks its back in this session.
bool _asksBack(Card card) =>
_options.backFirst &&
!_writes(card) &&
(card.content.type == CardType.frontBack ||
card.content.type == CardType.vocabulary);
/// How [card] is studied in this session.
StudyMode _modeOf(Card card) => _options.mode == LearnMode.cram
? StudyMode.cram
: _writes(card)
? StudyMode.write
: _asksBack(card)
? StudyMode.reverse
: StudyMode.review;
/// The schedules of [ids], each in the direction its card is studied.
/// The schedules of [ids], each in the direction its card is studied:
/// written by hand, back first or front first.
Future<Map<String, CardSchedule>> _schedules(List<String> ids) async {
final forward = await _reviews.schedulesOf(ids);
if (!_options.backFirst) {
if (!_options.backFirst && !_options.writeByHand) {
return forward;
}
final backward = await _reviews.schedulesOf(ids, mode: StudyMode.reverse);
final backward = _options.backFirst
? await _reviews.schedulesOf(ids, mode: StudyMode.reverse)
: forward;
final written = _options.writeByHand
? await _reviews.schedulesOf(ids, mode: StudyMode.write)
: forward;
return {
for (final id in ids)
id: _asksBack(_cardsById[id]!) ? backward[id]! : forward[id]!,
id: switch (_cardsById[id]!) {
final card when _writes(card) => written[id]!,
final card when _asksBack(card) => backward[id]!,
_ => forward[id]!,
},
};
}
@ -463,6 +484,7 @@ class StudySessionBloc(
bool _typesReading(Card card) =>
_options.typeReadings &&
!_options.quizVocabulary &&
!_writes(card) &&
card.content.type == CardType.vocabulary &&
card.content.reading.trim().isNotEmpty &&
// A word written in kana (テレビ) is its own reading.
@ -486,7 +508,7 @@ class StudySessionBloc(
return StudySessionStateShowing(
card: card,
// The same question each time the word comes, its order shuffled.
quiz: _options.quizVocabulary
quiz: _options.quizVocabulary && !_writes(card)
? vocabularyQuiz(
card.content,
aspect: _options.quizAspect,
@ -495,6 +517,7 @@ class StudySessionBloc(
)
: null,
typesReading: _typesReading(card),
writes: _writes(card),
studysetId: _studysetOf[id] ?? _studysetId,
tags: _tagging.of(id),
asksBack: _asksBack(card),
@ -1108,6 +1131,9 @@ final class const StudySessionStateShowing({
/// Whether the word's reading is to be typed, in kana.
final bool typesReading = false,
/// Whether the character, or the word, is to be written by hand.
final bool writes = false,
/// The studyset it is in.
required final String studysetId,
@ -1178,6 +1204,7 @@ final class const StudySessionStateShowing({
card: card,
quiz: quiz,
typesReading: typesReading,
writes: writes,
studysetId: studysetId,
tags: tags,
asksBack: asksBack,
@ -1204,6 +1231,7 @@ final class const StudySessionStateShowing({
card,
quiz,
typesReading,
writes,
studysetId,
tags,
asksBack,

View file

@ -38,6 +38,9 @@ enum StudyMode(
/// Back to front, on a schedule of its own, as Anki's reverse cards have.
reverse('reverse'),
/// Written by hand, stroke by stroke, on a schedule of its own.
write('write'),
/// Outside the schedule: counts as studying, never moves a schedule.
cram('cram'),
@ -46,5 +49,6 @@ enum StudyMode(
reset('reset');
/// Whether this is a rating of the card, as studying counts it.
bool get rates => this == review || this == reverse || this == cram;
bool get rates =>
this == review || this == reverse || this == write || this == cram;
}

View file

@ -68,6 +68,11 @@ final class const StudyOptions({
/// kana alone, and those asked back first, flip as before. Not together
/// with [quizVocabulary], which the settings turn off.
final bool typeReadings = false,
/// Whether character cards and words are written by hand, stroke by
/// stroke, on a schedule of their own; words so written are neither
/// quizzed nor typed.
final bool writeByHand = false,
}) with Equatable {
/// The fewest cards a round holds.
static const minCardsPerRound = 5;
@ -97,6 +102,7 @@ final class const StudyOptions({
bool? quizVocabulary,
VocabularyQuizAspect? quizAspect,
bool? typeReadings,
bool? writeByHand,
}) => StudyOptions(
mode: mode ?? this.mode,
order: order ?? this.order,
@ -112,6 +118,7 @@ final class const StudyOptions({
quizVocabulary: quizVocabulary ?? this.quizVocabulary,
quizAspect: quizAspect ?? this.quizAspect,
typeReadings: typeReadings ?? this.typeReadings,
writeByHand: writeByHand ?? this.writeByHand,
);
@override
@ -124,5 +131,6 @@ final class const StudyOptions({
quizVocabulary,
quizAspect,
typeReadings,
writeByHand,
];
}

View file

@ -6,7 +6,7 @@ import 'package:material_ui/material_ui.dart';
/// The four ratings, side by side: each a tile in its colour with a face
/// and its name, and below it, in spaced repetition, when the card would
/// come back.
/// come back. The [suggested] one, if any, is outlined.
class const RatingRow({
/// When the card would be due after each rating; none while cramming.
required final Map<Rating, DateTime> nextDue,
@ -16,6 +16,10 @@ class const RatingRow({
/// Called with the rating chosen.
required final ValueChanged<Rating> onRated,
/// The rating how the card went suggests, such as writing it with no
/// mistakes Good.
final Rating? suggested,
super.key,
}) extends StatelessWidget {
@override
@ -36,6 +40,7 @@ class const RatingRow({
null => null,
},
onTap: () => onRated(rating),
suggested: rating == suggested,
),
),
],
@ -51,6 +56,7 @@ class const _RatingTile({
required final Color color,
required final String? wait,
required final VoidCallback onTap,
final bool suggested = false,
}) extends StatefulWidget {
@override
State<_RatingTile> createState() => _RatingTileState();
@ -78,6 +84,8 @@ class _RatingTileState() extends State<_RatingTile> {
color: Colors.white.withValues(alpha: 0.65),
width: 2,
)
: widget.suggested
? BorderSide(color: theme.colorScheme.onSurface, width: 3)
: BorderSide.none,
borderRadius: BorderRadius.circular(8),
),

View file

@ -19,6 +19,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/study_lock.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_settings_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/swipe_to_rate.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/writing_body.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_picker_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -398,6 +399,22 @@ class const _Showing(
onRated: rate,
);
final body = switch (card.content.type) {
// A character, or a word, written by hand.
CardType.character ||
CardType.vocabulary when state.writes => WritingBody(
key: key,
card: card,
header: cardHeader,
checked: state.answerShown,
onCheck: show,
ratings: (suggested) => RatingRow(
nextDue: state.nextDue,
shownAt: state.shownAt,
onRated: rate,
suggested: suggested,
),
ratingFailed: state.ratingFailed,
),
// A word whose reading is typed.
CardType.vocabulary when state.typesReading => TypedReadingBody(
key: key,
@ -524,6 +541,7 @@ class const _Showing(
// Cards answered by picking or sorting have the ratings in their own
// bodies, once checked; the others always below.
final ratedBelow = switch (card.content.type) {
_ when state.writes => false,
CardType.vocabulary when state.quiz != null || state.typesReading =>
false,
CardType.frontBack || CardType.vocabulary || CardType.character => true,

View file

@ -328,6 +328,17 @@ class const _Options({required final StudyOptions options})
title: Text(l10n.studySettingsTypeReading),
subtitle: Text(l10n.studySettingsTypeReadingDescription),
),
const SizedBox(height: 20),
Text(l10n.studySettingsCharacters, style: heading),
const SizedBox(height: 4),
SwitchListTile(
value: options.writeByHand,
onChanged: (write) => change(options.copyWith(writeByHand: write)),
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(l10n.studySettingsWriteByHand),
subtitle: Text(l10n.studySettingsWriteByHandDescription),
),
],
);
}

View file

@ -0,0 +1,386 @@
import 'dart:async';
import 'dart:math' as math;
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/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/character_strokes.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/stroke_check.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/writing_canvas.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/rating_row.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_card_body.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
// Material's Card widget would clash with the domain's Card.
import 'package:material_ui/material_ui.dart' hide Card;
/// A character, or a word, written by hand: what it means (and how a
/// kanji or the word is read) on top, a square to write it in, character
/// by character, each stroke checked as it ends; a hint a tap away, and
/// below the card a way to give up and see it. Characters there is no
/// stroke order for are written for the person. Once written, or seen,
/// how many mistakes there were, and the ratings with the one they
/// suggest outlined ([suggestedRating]; any given up on, Again).
class const WritingBody({
required final Card card,
/// The row above the card's content.
required final Widget header,
required final bool checked,
/// Shows the answer: called once the card is written, or given up on.
required final VoidCallback onCheck,
/// The ratings, the suggested one outlined.
required final Widget Function(Rating suggested) ratings,
/// Whether the last rating of the card couldn't be kept.
final bool ratingFailed = false,
super.key,
}) extends StatefulWidget {
@override
State<WritingBody> createState() => _WritingBodyState();
}
/// The widest the square to write in grows.
const _largest = 360;
class _WritingBodyState() extends State<WritingBody> {
/// The characters to write, in order, each with how it is written (null
/// for one without a stroke order); null while they are read.
List<(String, CharacterStrokes?)>? _characters;
/// Which of [_characters] is being written.
var _at = 0;
var _mistakes = 0;
var _hinting = false;
var _gaveUp = false;
/// What the last stroke that didn't count was, said for a moment.
StrokeVerdict? _said;
Timer? _saying;
String get _text => widget.card.content.front.plainText.trim();
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_characters == null) {
unawaited(_read());
}
}
@override
void dispose() {
_saying?.cancel();
super.dispose();
}
Future<void> _read() async {
final strokes = DependenciesScope.of(context).strokes;
final characters = [
for (final rune in _text.runes)
if (String.fromCharCode(rune).trim().isNotEmpty)
String.fromCharCode(rune),
];
final read = [
for (final character in characters)
(
character,
strokes.read(character) ?? await strokes.strokesOf(character),
),
];
if (!mounted) {
return;
}
setState(() {
_characters = read;
_at = _nextFrom(0);
});
if (_at >= read.length) {
// Nothing to write here: the answer shows at once.
widget.onCheck();
}
}
/// The first character from [index] on that has strokes to write.
int _nextFrom(int index) {
final characters = _characters!;
var at = index;
while (at < characters.length && characters[at].$2 == null) {
at++;
}
return at;
}
void _onStroke(StrokeVerdict verdict) {
if (verdict == StrokeVerdict.right) {
return;
}
_saying?.cancel();
setState(() {
_mistakes++;
_said = verdict;
});
_saying = Timer(const Duration(seconds: 2), () {
if (mounted) {
setState(() => _said = null);
}
});
}
void _onWritten() {
setState(() {
_hinting = false;
_at = _nextFrom(_at + 1);
});
if (_at >= _characters!.length) {
widget.onCheck();
}
}
/// A hint counts as a mistake.
void _hint() => setState(() {
_hinting = true;
_mistakes++;
});
void _giveUp() {
setState(() => _gaveUp = true);
widget.onCheck();
}
Rating get _suggested => _gaveUp ? Rating.again : suggestedRating(_mistakes);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final content = widget.card.content;
final characters = _characters;
final writable =
characters?.any((character) => character.$2 != null) ?? true;
final current = characters != null && _at < characters.length
? characters[_at]
: null;
final checked = widget.checked;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SectionCard(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
widget.header,
const SizedBox(height: 16),
_Prompt(content: content),
if (characters != null && characters.length > 1) ...[
const SizedBox(height: 12),
_Slots(
characters: [
for (final (character, _) in characters) character,
],
at: checked ? characters.length : _at,
),
],
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final size = math
.min(
constraints.maxWidth,
constraints.maxHeight,
)
.clamp(0, _largest)
.toDouble();
return Center(
child: switch ((characters, current)) {
(null, _) => const AppLoadingIndicator(),
(_, (_, final strokes?)) => WritingCanvas(
key: ValueKey(_at),
strokes: strokes,
size: size,
hinting: _hinting,
revealed: _gaveUp,
onStroke: _onStroke,
onWritten: _onWritten,
),
_ when !writable => Text(
l10n.writingNoStrokes,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: muted,
),
),
// Written: the character, or the word, as it is.
_ => Text(
_text,
textAlign: TextAlign.center,
style: theme.textTheme.displayLarge,
),
},
);
},
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
switch (checked ? null : _said) {
StrokeVerdict.wrongOrder => l10n.writingWrongOrder,
StrokeVerdict.wrong => l10n.writingWrong,
_ => l10n.writingMistakes(_mistakes),
},
style: theme.textTheme.bodyMedium?.copyWith(
color: checked || _said == null
? muted
: theme.colorScheme.error,
),
),
),
if (!checked && current != null)
TextButton.icon(
onPressed: _hinting ? null : _hint,
icon: const Icon(LucideIcons.lightbulb, size: 18),
label: Text(l10n.writingHint),
)
else if (checked && content.type == CardType.character)
TextButton.icon(
onPressed: () => showCharacterSheet(
context,
_text,
facts: (
meanings: content.meanings,
onReadings: content.onReadings,
kunReadings: content.kunReadings,
marks: const [],
),
),
icon: const Icon(LucideIcons.brush, size: 18),
label: Text(l10n.kanjiStrokeOrder),
),
],
),
],
),
),
),
const SizedBox(height: 16),
if (widget.ratingFailed) ...[
const RatingFailedBanner(),
const SizedBox(height: 12),
],
if (checked) ...[
Text(
l10n.writingSuggested(ratingLabel(l10n, _suggested)),
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(color: muted),
),
const SizedBox(height: 8),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: widget.ratings(_suggested),
),
),
] else
FilledButton.tonal(
onPressed: current == null ? null : _giveUp,
child: Text(l10n.writingShow),
),
],
);
}
}
/// What to write: a character's meanings (a kana's romaji) and its on and
/// kun readings; a word's reading and meanings.
class const _Prompt({required final CardContent content})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final meanings = [
if (content.senses.isEmpty)
...content.meanings
else
for (final sense in content.senses) ...sense.glosses,
].where((meaning) => meaning.trim().isNotEmpty).take(4).join(', ');
final readings = switch (content.type) {
CardType.character => [
if (content.onReadings.isNotEmpty)
'${l10n.characterOn} ${content.onReadings.join('、')}',
if (content.kunReadings.isNotEmpty)
'${l10n.characterKun} ${content.kunReadings.join('、')}',
],
_ => [if (content.reading.trim().isNotEmpty) content.reading.trim()],
};
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
meanings,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall,
),
for (final line in readings) ...[
const SizedBox(height: 4),
Text(
line,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(color: muted),
),
],
],
);
}
}
/// A word's characters as boxes in a row: those before [at] written, the
/// one at [at] being written, the rest empty.
class const _Slots({
required final List<String> characters,
required final int at,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Wrap(
alignment: WrapAlignment.center,
spacing: 6,
runSpacing: 6,
children: [
for (final (i, character) in characters.indexed)
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: i == at ? colors.primary : colors.outlineVariant,
width: i == at ? 2 : 1,
),
),
child: Text(
i < at ? character : '',
style: theme.textTheme.titleLarge,
),
),
],
);
}
}

View file

@ -0,0 +1,128 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/character_strokes.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/stroke_check.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
/// Characters whose strokes the checks are tried on: kanji of few and many
/// strokes, parallel ones whose order matters, crossing ones, and kana.
const _characters = ['三', '十', '日', '木', '食', '語', 'あ', 'ア'];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Map<String, List<List<StrokePoint>>> strokes;
setUpAll(() async {
final repository = StrokeRepository();
strokes = {
for (final character in _characters)
character: (await repository.strokesOf(character))!.polylines(),
};
});
/// [points] moved by [dx], [dy].
List<StrokePoint> moved(List<StrokePoint> points, double dx, double dy) => [
for (final (:x, :y) in points) (x: x + dx, y: y + dy),
];
/// [points] grown by [factor] around their middle.
List<StrokePoint> grown(List<StrokePoint> points, double factor) {
final cx = points.map((p) => p.x).reduce((a, b) => a + b) / points.length;
final cy = points.map((p) => p.y).reduce((a, b) => a + b) / points.length;
return [
for (final (:x, :y) in points)
(x: cx + (x - cx) * factor, y: cy + (y - cy) * factor),
];
}
/// [points] as a hand wavers: off the line by up to [amplitude], twice
/// along it.
List<StrokePoint> wavering(List<StrokePoint> points, double amplitude) => [
for (final (i, (:x, :y)) in points.indexed)
(
x: x + amplitude * math.sin(i / points.length * 4 * math.pi),
y: y + amplitude * math.cos(i / points.length * 4 * math.pi),
),
];
test('a stroke written as it should be counts, also moved a little, '
'wavering, or a size larger or smaller', () {
for (final MapEntry(key: character, value: lines) in strokes.entries) {
for (final (i, line) in lines.indexed) {
for (final (how, written) in [
('as drawn', line),
('moved', moved(line, 5, -5)),
('wavering', wavering(line, 1.5)),
('larger', grown(line, 1.2)),
('smaller', grown(line, 0.85)),
]) {
expect(
checkStroke(written, lines, i),
StrokeVerdict.right,
reason: '$character, stroke ${i + 1}, $how',
);
}
}
}
});
test('a stroke written the other way round does not count, but for a '
'dot, which goes no way to speak of', () {
for (final MapEntry(key: character, value: lines) in strokes.entries) {
for (final (i, line) in lines.indexed) {
if (lengthOf(line) < 12) {
continue;
}
expect(
checkStroke(line.reversed.toList(), lines, i),
isNot(StrokeVerdict.right),
reason: '$character, stroke ${i + 1}',
);
}
}
});
test('a later stroke written before its turn is out of order', () {
for (final MapEntry(key: character, value: lines) in strokes.entries) {
for (var i = 0; i < lines.length; i++) {
for (var j = i + 1; j < lines.length; j++) {
expect(
checkStroke(lines[j], lines, i),
StrokeVerdict.wrongOrder,
reason: '$character: stroke ${j + 1} for ${i + 1}',
);
}
}
}
});
test('a stroke nowhere near any to come is wrong', () {
final lines = strokes['十']!;
// A diagonal through the box.
final slash = [
for (var t = 0.0; t <= 1; t += 0.02) (x: 10 + 80 * t, y: 10 + 80 * t),
];
expect(checkStroke(slash, lines, 0), StrokeVerdict.wrong);
expect(checkStroke(const [(x: 50, y: 50)], lines, 0), StrokeVerdict.wrong);
});
test('the rating suggested: none wrong Good, one or two Hard, more '
'Again', () {
expect(
[
for (final mistakes in [0, 1, 2, 3, 7]) suggestedRating(mistakes),
],
[Rating.good, Rating.hard, Rating.hard, Rating.again, Rating.again],
);
});
test('resampled: evenly along the line, the ends kept', () {
final points = resampled(const [(x: 0, y: 0), (x: 10, y: 0)], 11);
expect(points, hasLength(11));
expect(points.first, (x: 0.0, y: 0.0));
expect(points[5].x, closeTo(5, 1e-9));
expect(points.last, (x: 10.0, y: 0.0));
});
}

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