feat(pdf): cards beside a PDF are written in the composer's own editor

The sidebar wrote a front and a back only. It now holds the composer's
editor with a bloc of its own: every kind of card (multiple choice,
vocabulary with Look up, matching, a map, a character), tags, hints and
the explanation, pinning, duplicating and deleting. A new card is tied
to the page shown, which it can be moved to the current one, and waits
for its chapter's tag by name until it is created; text sent from the
pages goes into its front or its answer, as its kind keeps one. Create
saves it once it is complete, as the composer does; going back asks
before discarding what was written.

The screenshot of the pages has a button at the end of the format bar,
outside its scrolling, where an editor's place is for it
(InsertableBlock.inFormatBar); the Insert menu still lists it.

A new card no longer shows twice in the composer when its stored copy
arrives while it is saved.

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-12 23:08:33 +02:00
commit fcee3c2670
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 714 additions and 372 deletions

View file

@ -146,7 +146,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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. - **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`, `deletedAt`, `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. - **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`, `deletedAt`, `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. - **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' 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' 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. Beside the pages, cards are written in the composer's own editor (`CardDraftEditor`, with a `CardComposerBloc` of the panel's; decided 2026-10-12), every kind with its tags, hints and explanation, tied to the page shown and tagged with its chapter, and saved by Create, as the composer saves a card only once it is complete; the screenshot's button ends the format bar there (`InsertableBlock.inFormatBar`).
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters. Beside them a Radicals tab lists the 214 Kangxi radicals by their strokes (`feature/kanji/model/radical.dart`: the ideograph each of Unicode's Kangxi radicals stands for, the forms Japanese writes it as inside kanji, Unicode's English name), each listing the kanji KANJIDIC2 files under it (its classical radical, the label `radical:N`, which the curator writes), to add or see; that tab shares the characters' bloc, so what it adds shows as held in a search too. The curator stamps its files with how it converted them (`.conversion`, `conversion` in its `update.dart`): raise it whenever it writes something else from the same download, and instances convert again as soon as they run the new curator rather than up to a week later. - **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters. Beside them a Radicals tab lists the 214 Kangxi radicals by their strokes (`feature/kanji/model/radical.dart`: the ideograph each of Unicode's Kangxi radicals stands for, the forms Japanese writes it as inside kanji, Unicode's English name), each listing the kanji KANJIDIC2 files under it (its classical radical, the label `radical:N`, which the curator writes), to add or see; that tab shares the characters' bloc, so what it adds shows as held in a search too. The curator stamps its files with how it converted them (`.conversion`, `conversion` in its `update.dart`): raise it whenever it writes something else from the same download, and instances convert again as soon as they run the new curator rather than up to a week later.
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`). - **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`.

View file

@ -135,6 +135,41 @@ final class RichContent(
yield* visit(editorJson['document']); yield* visit(editorJson['document']);
} }
/// This content with [text] after it, a paragraph per line: as text
/// taken from a PDF joins what is written. An empty last line takes the
/// text rather than staying above it.
RichContent withParagraphs(String text) {
final document =
editorJson['document'] as Map<String, Object?>? ??
const {'type': 'page'};
final children = [...?document['children'] as List<Object?>?];
if (children.lastOrNull
case {
'type': 'paragraph',
'data': {'delta': final List<Object?> delta},
}
when delta.isEmpty) {
children.removeLast();
}
return RichContent({
'document': {
...document,
'children': [
...children,
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
}, formatVersion: formatVersion);
}
/// The JSON stored in the content payload. /// The JSON stored in the content payload.
Map<String, Object?> toJson() => { Map<String, Object?> toJson() => {
'formatVersion': formatVersion, 'formatVersion': formatVersion,

View file

@ -43,6 +43,10 @@ final class const InsertableBlock({
/// Whether it may go inside a callout; else it goes after the callout. /// Whether it may go inside a callout; else it goes after the callout.
final bool insideCallouts = true, final bool insideCallouts = true,
/// Whether the format bar has a button of its own for it too, at its
/// end: for what the editor's place is for (a screenshot beside a PDF).
final bool inFormatBar = false,
}); });
/// The blocks an editor knows: how each type is built, which of them /// The blocks an editor knows: how each type is built, which of them

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.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/content/content_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/editor_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/link.dart'; import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart'; import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart'; import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
@ -12,7 +13,9 @@ import 'package:material_ui/material_ui.dart';
/// Formats the selection of [editorState]: alignment, bold, italic, /// Formats the selection of [editorState]: alignment, bold, italic,
/// underline, a link, superscript and subscript, furigana, and with [onFormula] formulas /// underline, a link, superscript and subscript, furigana, and with [onFormula] formulas
/// within the line. Each button shows whether the selection has its format /// within the line. Each button shows whether the selection has its format
/// already. With [onInsert], an Insert button comes first, for blocks. /// already. With [onInsert], an Insert button comes first, for blocks; the
/// [pinned] blocks have buttons at the end, outside the scrolling, so they
/// are always at hand.
/// ///
/// The bar takes no focus, so tapping a button leaves the editor focused and /// The bar takes no focus, so tapping a button leaves the editor focused and
/// its selection where it was; on phones, where there are no shortcut keys, /// its selection where it was; on phones, where there are no shortcut keys,
@ -25,6 +28,12 @@ class const FormatBar({
/// Called by the formula button (Σ), which shows only with it. /// Called by the formula button (Σ), which shows only with it.
final VoidCallback? onFormula, final VoidCallback? onFormula,
/// Blocks with a button of their own at the end.
final List<InsertableBlock> pinned = const [],
/// Called with the pinned block whose button was pressed.
final ValueChanged<InsertableBlock>? onPinned,
super.key, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
/// How tall the bar is, so what docks it can make room for it. /// How tall the bar is, so what docks it can make room for it.
@ -120,10 +129,7 @@ class _FormatBarState() extends State<FormatBar> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final editorState = widget.editorState; final editorState = widget.editorState;
return ExcludeFocus( final formats = SingleChildScrollView(
child: SizedBox(
height: FormatBar.height,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row( child: Row(
@ -216,6 +222,25 @@ class _FormatBarState() extends State<FormatBar> {
), ),
], ],
), ),
);
return ExcludeFocus(
child: SizedBox(
height: FormatBar.height,
child: Row(
children: [
Expanded(child: formats),
if (widget.pinned.isNotEmpty) ...[
const _Divider(),
for (final block in widget.pinned)
_FormatButton(
icon: block.icon,
tooltip: block.name(l10n),
active: false,
onPressed: () => widget.onPinned?.call(block),
),
const SizedBox(width: 4),
],
],
), ),
), ),
); );

View file

@ -126,6 +126,12 @@ class _RichContentEditorState() extends State<RichContentEditor> {
if (!mounted) { if (!mounted) {
return; return;
} }
await _insertBlock(block, at);
}
/// Inserts [block], if any, at [at], and gives the editor its caret and
/// focus back.
Future<void> _insertBlock(InsertableBlock? block, Selection? at) async {
if (block != null) { if (block != null) {
await block.insert(context, _editorState, at); await block.insert(context, _editorState, at);
} }
@ -228,6 +234,12 @@ class _RichContentEditorState() extends State<RichContentEditor> {
editorState: _editorState, editorState: _editorState,
onInsert: _insert, onInsert: _insert,
onFormula: _formula, onFormula: _formula,
pinned: [
for (final block in blocksOf(context).insertable)
if (block.inFormatBar) block,
],
onPinned: (block) =>
unawaited(_insertBlock(block, _editorState.selection)),
), ),
], ],
], ],

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.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/card/model/card_content.dart';
@ -38,6 +39,19 @@ class CardComposerBloc(
), ),
); );
on<CardComposerEventLookedUp>(_onLookedUp); on<CardComposerEventLookedUp>(_onLookedUp);
on<CardComposerEventTextAdded>(
(event, emit) => _update(
emit,
event.id,
(draft) => draft.copyWith(
content: draft.content.withTextAdded(
event.text,
toAnswer: event.toAnswer,
),
),
),
);
on<CardComposerEventDiscarded>(_onDiscarded);
on<CardComposerEventVersionChosen>( on<CardComposerEventVersionChosen>(
(event, emit) => _update( (event, emit) => _update(
emit, emit,
@ -199,7 +213,7 @@ class CardComposerBloc(
final previous = state.byId(event.after); final previous = state.byId(event.after);
var content = CardContent.empty(); var content = CardContent.empty();
var tagIds = <String>{}; var tagIds = <String>{};
var newTagNames = const <String>[]; var newTagNames = <String>[];
if (previous != null) { if (previous != null) {
final from = previous.content; final from = previous.content;
content = content.withField(CardField.type, from.type); content = content.withField(CardField.type, from.type);
@ -220,13 +234,22 @@ class CardComposerBloc(
} }
if (state.pins.contains(CardPin.tags)) { if (state.pins.contains(CardPin.tags)) {
tagIds = {...previous.tagIds}; tagIds = {...previous.tagIds};
newTagNames = previous.newTagNames; newTagNames = [...previous.newTagNames];
}
}
if (event.source case final source?) {
content = content.copyWith(source: source);
}
for (final name in event.tagNames) {
if (!newTagNames.contains(name)) {
newTagNames.add(name);
} }
} }
emit( emit(
state.copyWith( state.copyWith(
drafts: [ drafts: [
_withStatus( _withStatus(
_withExisting(
CardDraft( CardDraft(
id: event.id, id: event.id,
content: content, content: content,
@ -235,6 +258,8 @@ class CardComposerBloc(
savedTagIds: const {}, savedTagIds: const {},
newTagNames: newTagNames, newTagNames: newTagNames,
), ),
state.tagging,
),
), ),
...state.drafts, ...state.drafts,
], ],
@ -291,6 +316,38 @@ class CardComposerBloc(
); );
}); });
/// A new card goes; a stored one is as stored again, its tags too.
void _onDiscarded(
CardComposerEventDiscarded event,
Emitter<CardComposerState> emit,
) {
final draft = state.byId(event.id);
if (draft == null || draft.status == CardDraftStatus.saving) {
return;
}
if (draft.cardId == null) {
emit(
state.copyWith(
drafts: [
for (final each in state.drafts)
if (each.id != draft.id) each,
],
),
);
return;
}
_update(
emit,
draft.id,
(d) => d.copyWith(
content: d.saved,
tagIds: d.savedTagIds,
newTagNames: const [],
resolved: const {},
),
);
}
Future<void> _onLeft( Future<void> _onLeft(
CardComposerEventLeft event, CardComposerEventLeft event,
Emitter<CardComposerState> emit, Emitter<CardComposerState> emit,
@ -326,6 +383,16 @@ class CardComposerBloc(
if (!_sameSet(tagIds, draft.savedTagIds)) { if (!_sameSet(tagIds, draft.savedTagIds)) {
await _tags.setTagsOf(cardId, tagIds); await _tags.setTagsOf(cardId, tagIds);
} }
// The stored card may have come in while it was saved: the draft
// holds it now.
emit(
state.copyWith(
existing: [
for (final each in state.existing)
if (each.cardId != cardId || each.id == draft.id) each,
],
),
);
_update(emit, draft.id, (d) { _update(emit, draft.id, (d) {
// Tags made for names it still waits for are its own now. // Tags made for names it still waits for are its own now.
final waiting = [ final waiting = [
@ -688,9 +755,42 @@ final class const CardComposerEventDraftAdded(
/// The draft the person was on, whose type and pinned fields it takes. /// The draft the person was on, whose type and pinned fields it takes.
final String? after, final String? after,
/// The page of a PDF it is written from.
final PdfPageRef? source,
/// Tags it is to have by name, made if need be once it is saved: the
/// chapter of a PDF it is written from.
final List<String> tagNames = const [],
}) extends CardComposerEvent { }) extends CardComposerEvent {
@override @override
List<Object?> get props => [id, after]; List<Object?> get props => [id, after, source, tagNames];
}
/// Text taken from elsewhere (a PDF's selection) goes after the card
/// [id]'s front, or after its answer ([CardContent.withTextAdded]).
final class const CardComposerEventTextAdded(
/// The draft's id.
final String id, {
/// The text.
required final String text,
/// Whether it goes to the answer rather than the front.
required final bool toAnswer,
}) extends CardComposerEvent {
@override
List<Object?> get props => [id, text, toAnswer];
}
/// Leave the card [id] unsaved: a new one goes, a stored one is as stored
/// again.
final class const CardComposerEventDiscarded(
/// The draft's id.
final String id,
) extends CardComposerEvent {
@override
List<Object?> get props => [id];
} }
/// The person changed the card [id] to [content]. /// The person changed the card [id] to [content].

View file

@ -445,6 +445,41 @@ final class const CardContent({
/// says: its reading then shows from the start while studying. /// says: its reading then shows from the start while studying.
bool get usuallyKana => senses.firstOrNull?.misc.contains('uk') ?? false; bool get usuallyKana => senses.firstOrNull?.misc.contains('uk') ?? false;
/// This content with [text] taken from elsewhere (a PDF's selection)
/// after its front, or after its answer as its type has one: a
/// front/back card's back, a multiple-choice card's first right answer, a
/// word's or a character's meanings as one more (a word with senses
/// takes its meanings from them). A map question's answer is a region,
/// and matching has no one answer: they take none.
CardContent withTextAdded(String text, {required bool toAnswer}) {
if (!toAnswer) {
return copyWith(front: front.withParagraphs(text));
}
final line = text.replaceAll(RegExp(r'\s*\n\s*'), ' ').trim();
return switch (type) {
CardType.frontBack => copyWith(back: back.withParagraphs(text)),
CardType.multipleChoice when map == null => switch (answers.indexWhere(
(answer) => answer.correct,
)) {
-1 => this,
final right => copyWith(
answers: [...answers]
..[right] = answers[right].copyWith(
content: answers[right].content.withParagraphs(text),
),
),
},
CardType.vocabulary || CardType.character when senses.isEmpty => copyWith(
meanings: [
for (final meaning in meanings)
if (meaning.trim().isNotEmpty) meaning,
line,
],
),
_ => this,
};
}
/// Whether none of its type's fields holds anything. /// Whether none of its type's fields holds anything.
bool get isBlank => switch (type) { bool get isBlank => switch (type) {
CardType.frontBack => _blank(front) && _blank(back), CardType.frontBack => _blank(front) && _blank(back),

View file

@ -132,6 +132,9 @@ class const CardDraftEditor({
/// Suggests the card as it is to the author of the published studyset it /// Suggests the card as it is to the author of the published studyset it
/// came from; null for a card of the person's own. /// came from; null for a card of the person's own.
final VoidCallback? onSuggest, final VoidCallback? onSuggest,
/// Around it all: less in a narrow place, beside a PDF.
final EdgeInsetsGeometry padding = const EdgeInsets.all(24),
super.key, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
@ -292,7 +295,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
CardKind.character => l10n.composerLabelCharacter, CardKind.character => l10n.composerLabelCharacter,
}; };
return Padding( return Padding(
padding: const EdgeInsets.all(24), padding: widget.padding,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [

View file

@ -49,6 +49,7 @@ EditorBlocks screenshotBlocks(PdfCapture capture, String pdfId) => EditorBlocks(
icon: LucideIcons.crop, icon: LucideIcons.crop,
group: InsertGroup.media, group: InsertGroup.media,
keywords: const ['screenshot', 'capture', 'clip', 'pdf', 'image'], keywords: const ['screenshot', 'capture', 'clip', 'pdf', 'image'],
inFormatBar: true,
insert: (context, editorState, at) async { insert: (context, editorState, at) async {
final shot = await capture.request(); final shot = await capture.request();
if (shot == null) { if (shot == null) {

View file

@ -1,20 +1,24 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
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/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart' hide Card; import 'package:material_ui/material_ui.dart';
/// Where selected text of a PDF goes. /// Where selected text of a PDF goes.
enum PdfClipTarget() { enum PdfClipTarget() {
@ -52,42 +56,6 @@ String clippedText(String text, {required bool keepLineBreaks}) =>
.replaceAll(RegExp(r'\s*\r?\n\s*'), ' ') .replaceAll(RegExp(r'\s*\r?\n\s*'), ' ')
.trim(); .trim();
/// [content] with [text] added as a paragraph of its own at the end.
RichContent withParagraph(RichContent content, String text) {
final document =
content.editorJson['document'] as Map<String, Object?>? ??
const {'type': 'page'};
final children = [
...?document['children'] as List<Object?>?,
];
// An empty last line takes the text rather than staying above it.
if (children.lastOrNull
case {
'type': 'paragraph',
'data': {'delta': final List<Object?> delta},
}
when delta.isEmpty) {
children.removeLast();
}
return RichContent({
'document': {
...document,
'children': [
...children,
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
});
}
/// The panels beside a PDF's pages: its flashcards, and the note of the page /// The panels beside a PDF's pages: its flashcards, and the note of the page
/// shown. Text sent through [clips] goes where it was sent, the panel /// shown. Text sent through [clips] goes where it was sent, the panel
/// showing. /// showing.
@ -161,18 +129,11 @@ class _PdfReaderPanelsState()
} }
} }
/// A card being written in the panel: a new one, or `card` changed.
typedef _Draft = ({
Card? card,
RichContent front,
RichContent back,
int page,
int version,
});
/// The PDF's flashcards, by page or by chapter; a tap on one shows its page. /// The PDF's flashcards, by page or by chapter; a tap on one shows its page.
/// "Create flashcard" writes one, tied to the page shown and tagged with its /// "Create flashcard" writes one in the composer's own editor, every kind
/// chapter. /// of card with its tags, hints and explanation, tied to the page shown and
/// tagged with its chapter; text sent from the pages goes into the card
/// being written, or a new one.
class const _CardsPanel({ class const _CardsPanel({
required final PdfSource source, required final PdfSource source,
required final PdfReaderController controller, required final PdfReaderController controller,
@ -186,11 +147,18 @@ class const _CardsPanel({
class _CardsPanelState() class _CardsPanelState()
extends State<_CardsPanel> extends State<_CardsPanel>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
_Draft? _draft; late final CardComposerBloc _bloc;
/// The card being written, by its draft's id; null while the list shows.
String? _open;
/// The card whose saving is awaited, to go on once it is saved, and
/// whether it is a new one.
({String id, bool isNew})? _saving;
var _nextDraft = 0;
var _focusFront = false;
var _keepOpen = false; var _keepOpen = false;
var _byChapter = false; var _byChapter = false;
StreamSubscription<List<Card>>? _cards;
var _all = <Card>[];
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@ -198,128 +166,172 @@ class _CardsPanelState()
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final person = PersonScope.of(context);
_bloc = CardComposerBloc(
person.cards,
person.tags,
widget.source.studysetId,
)..add(const CardComposerEventStarted());
widget.clips.addListener(_onClip); widget.clips.addListener(_onClip);
_cards =
PersonScope.of(
context,
).cards
.watch(widget.source.studysetId)
.listen(
(cards) => setState(
() => _all = [
for (final card in cards)
if (card.content.source?.pdfId == widget.source.id) card,
],
),
);
} }
@override @override
void dispose() { void dispose() {
widget.clips.removeListener(_onClip); widget.clips.removeListener(_onClip);
unawaited(_cards?.cancel()); unawaited(_bloc.close());
super.dispose(); super.dispose();
} }
_Draft _newDraft() => ( /// Opens a new card, tied to the page shown and tagged with its chapter
card: null, /// (a chapter whose title is blank names no tag), after the one written
front: RichContent.empty(), /// before, whose kind it takes.
back: RichContent.empty(), String _add({required bool focus}) {
page: widget.controller.page, final page = widget.controller.page;
version: 0, final id = 'pdf-${_nextDraft++}';
_bloc.add(
CardComposerEventDraftAdded(
id,
after: _open,
source: (pdfId: widget.source.id, page: page),
tagNames: [
if (chapterOf(widget.chapters, page)?.title.trim() case final title?
when title.isNotEmpty)
title,
],
),
); );
setState(() {
_open = id;
_focusFront = focus;
});
return id;
}
void _onClip() { void _onClip() {
final clip = widget.clips.last; final clip = widget.clips.last;
if (clip == null || clip.target == PdfClipTarget.note) { if (clip == null || clip.target == PdfClipTarget.note) {
return; return;
} }
final draft = _draft ?? _newDraft(); final id = _open ?? _add(focus: false);
setState( _bloc.add(
() => _draft = ( CardComposerEventTextAdded(
card: draft.card, id,
front: clip.target == PdfClipTarget.question text: clip.text,
? withParagraph(draft.front, clip.text) toAnswer: clip.target == PdfClipTarget.answer,
: draft.front,
back: clip.target == PdfClipTarget.answer
? withParagraph(draft.back, clip.text)
: draft.back,
page: draft.page,
version: draft.version + 1,
), ),
); );
} }
Future<void> _save() async { /// Back to the list, asking first when that discards something written.
final draft = _draft; Future<void> _back(CardDraft draft) async {
if (draft == null) { final l10n = AppLocalizations.of(context);
final nothingToLose = draft.cardId == null
? draft.content.isBlank
: !draft.isDirty;
if (!nothingToLose &&
!await confirmDestructiveAction(
context,
title: l10n.composerDiscardTitle(1),
message: l10n.composerDiscardMessage(1),
confirmLabel: l10n.composerDiscardConfirm,
)) {
return; return;
} }
final l10n = AppLocalizations.of(context); _bloc.add(CardComposerEventDiscarded(draft.id));
final messenger = ScaffoldMessenger.of(context);
final person = PersonScope.of(context);
final page = draft.card == null ? widget.controller.page : draft.page;
final content = (draft.card?.content ?? CardContent.empty()).copyWith(
front: draft.front,
back: draft.back,
source: (pdfId: widget.source.id, page: page),
);
if (draft.card case final card?) {
await person.cards.save(card, content);
} else {
final id = await person.cards.create(widget.source.studysetId, content);
// Tagged with its chapter, so the chapter can be studied on its own.
// A chapter whose title is blank names no tag.
if (chapterOf(widget.chapters, page) case final chapter?
when chapter.title.trim().isNotEmpty) {
await person.tags.setTagsOf(id, {
await person.tags.create(chapter.title),
});
}
messenger.showSnackBar(
SnackBar(content: Text(l10n.readerCardCreated)),
);
}
if (mounted) { if (mounted) {
setState( setState(() => _open = null);
() => _draft = _keepOpen && draft.card == null ? _newDraft() : null,
);
} }
} }
Future<void> _delete(Card card) async { void _save(CardDraft draft) {
setState(() => _saving = (id: draft.id, isNew: draft.cardId == null));
_bloc.add(CardComposerEventLeft(draft.id));
}
/// Once the card awaited is saved: a new one says so, and the next opens
/// while creation stays open; else the list shows again.
void _onState(BuildContext context, CardComposerState state) {
final saving = _saving;
final draft = state.byId(saving?.id);
if (saving == null ||
draft == null ||
draft.status != CardDraftStatus.saved) {
return;
}
final created = saving.isNew;
_saving = null;
if (created) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).readerCardCreated)),
);
}
if (_keepOpen && created) {
_add(focus: true);
} else {
setState(() => _open = null);
}
}
Future<void> _delete(CardDraft draft) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final cards = PersonScope.of(context).cards; // A new card with nothing in it has nothing to lose.
if (await confirmDestructiveAction( if (!(draft.cardId == null && draft.content.isBlank) &&
!await confirmDestructiveAction(
context, context,
title: l10n.readerCardDeleteTitle, title: l10n.readerCardDeleteTitle,
message: l10n.readerCardDeleteMessage, message: l10n.readerCardDeleteMessage,
confirmLabel: l10n.readerCardDeleteConfirm, confirmLabel: l10n.readerCardDeleteConfirm,
)) { )) {
await cards.delete(card); return;
} }
_bloc.add(CardComposerEventDeleted(draft.id));
if (mounted && _open == draft.id) {
setState(() => _open = null);
}
}
void _duplicate(CardDraft draft) {
final id = 'pdf-${_nextDraft++}';
_bloc.add(CardComposerEventDuplicated(draft.id, id));
setState(() {
_open = id;
_focusFront = false;
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
final draft = _draft; return BlocProvider.value(
return draft == null ? _list(context) : _editor(context, draft); value: _bloc,
child: BlocConsumer<CardComposerBloc, CardComposerState>(
listener: _onState,
builder: (context, state) => switch (state.byId(_open)) {
final draft? => _editor(context, state, draft),
null => _list(context, state),
},
),
);
} }
Widget _list(BuildContext context) { Widget _list(BuildContext context, CardComposerState state) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final sections = <String, List<Card>>{}; final sections = <String, List<CardDraft>>{};
for (final card for (final draft
in [..._all]..sort( in [
(a, b) => a.content.source!.page.compareTo(b.content.source!.page), for (final draft in state.items)
if (draft.cardId != null &&
draft.saved.source?.pdfId == widget.source.id)
draft,
]..sort(
(a, b) => a.saved.source!.page.compareTo(b.saved.source!.page),
)) { )) {
final page = card.content.source!.page; final page = draft.saved.source!.page;
final heading = _byChapter final heading = _byChapter
? chapterOf(widget.chapters, page)?.title ?? l10n.readerFrontMatter ? chapterOf(widget.chapters, page)?.title ?? l10n.readerFrontMatter
: l10n.readerPage(page); : l10n.readerPage(page);
(sections[heading] ??= []).add(card); (sections[heading] ??= []).add(draft);
} }
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -338,7 +350,7 @@ class _CardsPanelState()
), ),
), ),
Expanded( Expanded(
child: _all.isEmpty child: sections.isEmpty
? Padding( ? Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@ -361,33 +373,28 @@ class _CardsPanelState()
) )
: ListView( : ListView(
children: [ children: [
for (final MapEntry(key: heading, value: cards) for (final MapEntry(key: heading, value: drafts)
in sections.entries) ...[ in sections.entries) ...[
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text( child: Text(
'$heading · ${cards.length}', '$heading · ${drafts.length}',
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
), ),
), ),
for (final card in cards) for (final draft in drafts)
_CardTile( _CardTile(
card: card, content: draft.saved,
onTap: () => widget.controller.goToPage( onTap: () => widget.controller.goToPage(
card.content.source!.page, draft.saved.source!.page,
), ),
onEdit: () => setState( onEdit: () => setState(() {
() => _draft = ( _open = draft.id;
card: card, _focusFront = false;
front: card.content.front, }),
back: card.content.back, onDelete: () => unawaited(_delete(draft)),
page: card.content.source!.page,
version: 0,
),
),
onDelete: () => unawaited(_delete(card)),
), ),
], ],
], ],
@ -398,50 +405,70 @@ class _CardsPanelState()
child: FilledButton.icon( child: FilledButton.icon(
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
label: Text(l10n.readerCreateCard), label: Text(l10n.readerCreateCard),
onPressed: () => setState(() => _draft = _newDraft()), onPressed: () => _add(focus: true),
), ),
), ),
], ],
); );
} }
Widget _editor(BuildContext context, _Draft draft) { Widget _editor(
BuildContext context,
CardComposerState state,
CardDraft draft,
) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
void change({RichContent? front, RichContent? back, int? page}) => final isNew = draft.cardId == null;
_draft = ( final page = draft.content.source?.page;
card: draft.card, return Column(
front: front ?? _draft!.front, crossAxisAlignment: CrossAxisAlignment.stretch,
back: back ?? _draft!.back,
page: page ?? _draft!.page,
version: _draft!.version,
);
return ListView(
padding: const EdgeInsets.fromLTRB(8, 4, 12, 12),
children: [ children: [
Row( Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 12, 0),
child: Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(LucideIcons.arrowLeft), icon: const Icon(LucideIcons.arrowLeft),
tooltip: l10n.readerBackToCards, tooltip: l10n.readerBackToCards,
onPressed: () => setState(() => _draft = null), onPressed: () => unawaited(_back(draft)),
), ),
Text( Expanded(
draft.card == null ? l10n.readerCreateCard : l10n.readerEditCard, child: Text(
isNew ? l10n.readerCreateCard : l10n.readerEditCard,
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),
if (draft.card != null) ),
if (page != null)
Padding( Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8), padding: const EdgeInsets.fromLTRB(16, 0, 12, 0),
child: Row( child: ListenableBuilder(
listenable: widget.controller,
builder: (context, _) => Row(
children: [ children: [
Text(l10n.readerReferencedPage(draft.page)), Expanded(
const Spacer(), child: Text(
l10n.readerReferencedPage(page),
style: theme.textTheme.bodySmall,
),
),
if (widget.controller.page != page)
TextButton( TextButton(
onPressed: () => setState( onPressed: () => _bloc.add(
() => change(page: widget.controller.page), CardComposerEventChanged(
draft.id,
draft.content.copyWith(
source: (
pdfId: widget.source.id,
page: widget.controller.page,
),
),
),
), ),
child: Text( child: Text(
l10n.readerUseCurrentPage(widget.controller.page), l10n.readerUseCurrentPage(widget.controller.page),
@ -450,88 +477,61 @@ class _CardsPanelState()
], ],
), ),
), ),
_Field(
key: ValueKey(('front', draft.version, draft.card?.id)),
label: l10n.readerQuestion,
content: draft.front,
onChanged: (front) => change(front: front),
), ),
const SizedBox(height: 12), Expanded(
_Field( child: CardDraftEditor(
key: ValueKey(('back', draft.version, draft.card?.id)), key: ValueKey(draft.id),
label: l10n.readerAnswer, draft: draft,
content: draft.back, pins: state.pins,
onChanged: (back) => change(back: back), tagging: state.tagging,
mapRegions: state.mapRegions,
focusFront: _focusFront,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
onDuplicate: () => _duplicate(draft),
onDelete: () => unawaited(_delete(draft)),
onSuggest: fromRelease(draft.content.published)
? () => suggestToAuthor(
context,
studysetId: widget.source.studysetId,
send: (feedback, body) => feedback.suggestCard(
draft.content.normalized,
body: body,
), ),
if (draft.card == null) )
CheckboxListTile( : null,
contentPadding: EdgeInsets.zero, ),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 12, 8),
child: Row(
children: [
if (isNew)
Expanded(
child: CheckboxListTile(
contentPadding: const EdgeInsets.only(left: 4),
dense: true,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
value: _keepOpen, value: _keepOpen,
onChanged: (value) => setState(() => _keepOpen = value ?? false), onChanged: (value) =>
setState(() => _keepOpen = value ?? false),
title: Text(l10n.readerKeepOpen), title: Text(l10n.readerKeepOpen),
), ),
const SizedBox(height: 8), )
Row( else
children: [
TextButton(
onPressed: () => setState(
() => _draft = draft.card == null
? _newDraft()
: (
card: draft.card,
front: RichContent.empty(),
back: RichContent.empty(),
page: draft.page,
version: draft.version + 1,
),
),
child: Text(l10n.readerClear),
),
const Spacer(), const Spacer(),
const SizedBox(width: 8),
FilledButton( FilledButton(
onPressed: () => unawaited(_save()), onPressed:
child: Text( draft.canSave && draft.status != CardDraftStatus.saving
draft.card == null ? l10n.readerCreate : l10n.commonSave, ? () => _save(draft)
), : null,
child: draft.status == CardDraftStatus.saving
? const ButtonLoadingIndicator()
: Text(isNew ? l10n.readerCreate : l10n.commonSave),
), ),
], ],
), ),
],
);
}
}
/// A field of a card in the panel: its label, and an editor with the
/// format bar while written.
class const _Field({
required final String label,
required final RichContent content,
required final ValueChanged<RichContent> onChanged,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 8, bottom: 4),
child: Text(label, style: theme.textTheme.labelLarge),
),
Container(
height: 180,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: RichContentEditor(
initialContent: content,
onChanged: onChanged,
formatBar: true,
),
), ),
], ],
); );
@ -539,7 +539,7 @@ class const _Field({
} }
class const _CardTile({ class const _CardTile({
required final Card card, required final CardContent content,
required final VoidCallback onTap, required final VoidCallback onTap,
required final VoidCallback onEdit, required final VoidCallback onEdit,
required final VoidCallback onDelete, required final VoidCallback onDelete,
@ -548,8 +548,8 @@ class const _CardTile({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final error = Theme.of(context).colorScheme.error; final error = Theme.of(context).colorScheme.error;
final front = previewOf(card.content.front, l10n); final front = previewOf(content.front, l10n);
final answer = card.content.answerPreview; final answer = content.answerPreview;
return MenuAnchor( return MenuAnchor(
menuChildren: [ menuChildren: [
MenuItemButton( MenuItemButton(
@ -691,8 +691,7 @@ class _NotePanelState()
if (clip == null || clip.target != PdfClipTarget.note || !_loaded) { if (clip == null || clip.target != PdfClipTarget.note || !_loaded) {
return; return;
} }
final body = withParagraph( final body = (_unsaved ?? _body ?? RichContent.empty()).withParagraphs(
_unsaved ?? _body ?? RichContent.empty(),
clip.text, clip.text,
); );
setState(() { setState(() {

View file

@ -595,4 +595,101 @@ void main() {
expect(await cards.cardsOf('japanese'), isEmpty); expect(await cards.cardsOf('japanese'), isEmpty);
expect(gateway.deletions, ['blank $neko', 'delete $neko']); expect(gateway.deletions, ['blank $neko', 'delete $neko']);
}); });
test('a card written from a PDF knows its page, waits for its chapter '
'tag by name, and takes text sent to its front and answer', () async {
start();
await loaded();
bloc
..add(
const CardComposerEventDraftAdded(
'new',
source: (pdfId: 'grammar', page: 5),
tagNames: ['Particles'],
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'は marks',
toAnswer: false,
),
)
..add(
const CardComposerEventTextAdded(
'new',
text: 'the topic',
toAnswer: true,
),
);
final draft = (await until(
(s) => s.byId('new')?.content.back.plainText == 'the topic',
)).byId('new')!;
expect(draft.content.front.plainText, 'は marks');
expect(draft.content.source, (pdfId: 'grammar', page: 5));
expect(draft.newTagNames, ['Particles']);
bloc.add(const CardComposerEventLeft('new'));
final saved = await until(
(s) => s.byId('new')?.status == CardDraftStatus.saved,
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.content.source, (pdfId: 'grammar', page: 5));
expect((await tags.read()).of(stored.id).single.name, 'Particles');
// Held by its draft alone, never also among the stored cards.
expect(saved.items.where((d) => d.cardId == stored.id), hasLength(1));
});
test('text for the answer goes where the kind of card keeps it', () {
final base = card('猫', '');
expect(
base.withTextAdded('cat', toAnswer: true).back.plainText,
'cat',
);
final word = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.vocabulary,
meanings: const ['cat'],
);
expect(
word.withTextAdded('feline\nanimal', toAnswer: true).meanings,
['cat', 'feline animal'],
);
final choice = CardContent(
front: text('猫'),
back: RichContent.empty(),
type: CardType.multipleChoice,
answers: [
CardAnswer(content: text('dog')),
CardAnswer(content: RichContent.empty(), correct: true),
],
);
expect(
choice.withTextAdded('cat', toAnswer: true).answers[1].content.plainText,
'cat',
);
});
test('discarding drops a new card, and takes a stored one back to how '
'it is stored', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged(neko, card('猫', 'kitten')))
..add(const CardComposerEventDiscarded('new'))
..add(CardComposerEventDiscarded(neko));
final state = await until(
(s) =>
s.byId('new') == null &&
s.byId(neko)?.content.back.plainText == 'cat',
);
expect(state.byId(neko)!.isDirty, isFalse);
expect(await cards.cardsOf('japanese'), hasLength(1));
});
} }

View file

@ -223,6 +223,18 @@ void main() {
find.textContaining('は marks the topic', findRichText: true), find.textContaining('は marks the topic', findRichText: true),
findsOneWidget, findsOneWidget,
); );
// A card is created once it has its answer too, as in the composer.
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Create'))
.onPressed,
isNull,
);
engine.select('the topic particle');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Answer'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Create')); await tester.tap(find.widgetWithText(FilledButton, 'Create'));
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -235,6 +247,7 @@ void main() {
final card = (await cards.cardsOf('japanese')).single; final card = (await cards.cardsOf('japanese')).single;
expect(card.content.source, (pdfId: id, page: 5)); expect(card.content.source, (pdfId: id, page: 5));
expect(card.content.front.plainText, 'は marks the topic'); expect(card.content.front.plainText, 'は marks the topic');
expect(card.content.back.plainText, 'the topic particle');
expect((await tags.read()).of(card.id).single.name, 'Particles'); expect((await tags.read()).of(card.id).single.name, 'Particles');
expect(find.text('Page 5 · 1'), findsOneWidget); expect(find.text('Page 5 · 1'), findsOneWidget);
}); });
@ -268,6 +281,24 @@ void main() {
expect(note.content.body.plainText, 'ね is a softener'); expect(note.content.body.plainText, 'ね is a softener');
}); });
testWidgets('the format bar beside a PDF ends with a screenshot button, and '
'the card is written as in the composer, its kind chosen', (
tester,
) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.text('Create flashcard'));
await tester.pumpAndSettle();
expect(find.widgetWithText(ChoiceChip, 'Vocabulary'), findsOneWidget);
await tester.tap(find.byTooltip('Screenshot of the page'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsOneWidget);
await tester.tap(find.text('Cancel screenshot'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsNothing);
});
testWidgets('a screenshot of a region of the page goes into the card as ' testWidgets('a screenshot of a region of the page goes into the card as '
'an image that knows its page, which a click selects', (tester) async { 'an image that knows its page, which a click selects', (tester) async {
final id = await stored(); final id = await stored();