feat(studyset): prepare for an exam, with plans, a date and the pace to it

A studyset plans for exams: each plan names what an exam covers, the
topics it takes whole (with their flashcards, learning paths and PDFs)
and the tags whose flashcards it takes from anywhere in the studyset.
The author's plans are published with the studyset (payload schema 7,
`examPlans`); copying a release and taking a newer one point their
topics at the copy's own studysets. Plans are also offered from level
tags: JLPT N5 to N1, HSK 1 to 7-9 and KRDict's levels, each taking the
levels below it, as the exams do.

The person chooses a plan and the exam's day (`exam`), which stays
theirs: the server leaves it out of a release, as a PDF's deadline.
The study plan then shows the days left, how many of the plan's
flashcards were started, its paths' steps done and its PDFs' pages
read, and the pace that gets everything new learnt a week before the
exam (by the exam when two weeks or fewer are left): new flashcards a
day, steps a week, pages a day. Study for the exam studies only what
the plan covers, its new cards at that pace (counted as the day began)
and every review due.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 14:26:08 +02:00
commit dd6c567ad0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
27 changed files with 2351 additions and 20 deletions

View file

@ -100,7 +100,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`) and Study plan (the paths finished; Next up: the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; later an exam's date and its schedule, `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`) and Study plan (the paths finished; Next up: the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
@ -145,7 +145,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **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 studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. 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, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.
- **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 studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. 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). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards anywhere in the studyset); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam when more than two weeks are left, by the exam otherwise; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. 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`, `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.
@ -166,7 +166,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. Its shared part, `serverpod_offline_sync`, comes from our fork (the root `pubspec.yaml`'s `dependency_overrides`, `project-halkyon/serverpod-offline-sync` on code.halkyon.app, branch `halkyon/byte-payloads`, decided 2026-10-12): v0.0.10 compares byte payloads byte by byte through a UUID parse that throws for each byte, so one sync touching a few thousand cards blocked the server for minutes. Move the fork along when upgrading, and drop it once a release has the fix.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own, and says why a handle couldn't be theirs (`checkHandle`, null when it can: whose a handle is, `byHandle` tells anyone already); `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it in a card of its own after the account's and the inbox's (each part a card, as Settings groups its own), as a form, for people with an identity, that saves as it is written (decided 2026-10-12): a field when it is left or Enter is pressed in it, a link when removed, never twice the same; the handle is chosen in a dialog that asks the instance while it is typed whether it is free, and choosing the first makes the profile with what was written (`PublicProfileBloc` takes saves one at a time, the latest of those waiting next, and a handle chosen holds for every save after it).
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. The library's cards say who made a studyset ("By you", or a copy's author by the handle in its `publishedAt`) and whether it is out on Explore (a lock or a globe, opening the Publish sheet through `showShareSheet`): `published()` lists the person's studysets published and not withdrawn, and `PublishRepository` remembers them in `published.json` in the person's folder (`PublishedStore`), so the library shows them offline too; publishing, withdrawing and looking one up update it.
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a studyset's `exam`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. The library's cards say who made a studyset ("By you", or a copy's author by the handle in its `publishedAt`) and whether it is out on Explore (a lock or a globe, opening the Publish sheet through `showShareSheet`): `published()` lists the person's studysets published and not withdrawn, and `PublishRepository` remembers them in `published.json` in the person's folder (`PublishedStore`), so the library shows them offline too; publishing, withdrawing and looking one up update it.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
- **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). There is no export into it yet.
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.

View file

@ -1787,6 +1787,36 @@
"planStepsDone": "{total, plural, =1{{done} von 1 Schritt} other{{done} von {total} Schritten}}",
"planNothing": "Gerade steht nichts an.",
"planLearningPaths": "Lernpfade",
"examPrepare": "Auf eine Prüfung vorbereiten",
"examPrepareHint": "Wähle, was sie abdeckt und wann sie ist, und bekomme ein Tempo, das dich hinbringt.",
"examChange": "Prüfung ändern",
"examDaysLeft": "{count, plural, =0{Heute, {date}} =1{Morgen, {date}} other{In {count} Tagen, {date}}}",
"examPast": "Sie war am {date}",
"examPlanGone": "Diesen Prüfungsplan gibt es nicht mehr",
"examCardsStarted": "{total, plural, =1{{started} von 1 Karteikarte begonnen} other{{started} von {total} Karteikarten begonnen}}",
"examPagesRead": "{total, plural, =1{{read} von 1 Seite gelesen} other{{read} von {total} Seiten gelesen}}",
"examPaceTitle": "Dein Tempo",
"examPaceNew": "{count, plural, =1{1 neue Karteikarte am Tag} other{{count} neue Karteikarten am Tag}}",
"examPaceSteps": "{count, plural, =1{1 Schritt pro Woche} other{{count} Schritte pro Woche}}",
"examPacePages": "{count, plural, =1{1 Seite am Tag} other{{count} Seiten am Tag}}",
"examPaceDone": "Alles Neue ist gelernt: bis zur Prüfung wiederholen.",
"examStudy": "Für die Prüfung lernen",
"examSheetHelp": "Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.",
"examDate": "Prüfungsdatum",
"examSheetPlans": "Prüfungspläne dieses Lernsets",
"examSheetFromTags": "Aus den Stufen-Tags",
"examSheetNoPlans": "Noch keine Prüfungspläne. Erstelle einen aus den Themen und Tags dieses Lernsets.",
"examRemove": "Nicht mehr vorbereiten",
"examPlanTopicCount": "{count, plural, =1{1 Thema} other{{count} Themen}}",
"examPlanNew": "Neuer Prüfungsplan",
"examPlanEdit": "Prüfungsplan bearbeiten",
"examPlanHelp": "Ein Prüfungsplan nennt, was eine Prüfung abdeckt: ganze Themen mit ihren Karteikarten, Lernpfaden und PDFs, und Karteikarten mit bestimmten Tags. Er wird mit dem Lernset veröffentlicht.",
"examPlanName": "Name",
"examPlanNameHint": "z. B. Abschlussprüfung",
"examPlanTopics": "Themen, die sie ganz abdeckt",
"examPlanTags": "Karteikarten mit diesen Tags",
"examPlanEmpty": "Dieses Lernset hat noch keine Themen oder Karteikarten mit Tags zur Auswahl.",
"examPlanDelete": "Plan löschen",
"jellyfinTitle": "Jellyfin",
"jellyfinSubtitle": "Schau Serien und Filme und hör Musik von deinem eigenen Jellyfin-Server und lerne die Wörter ihrer Untertitel und Liedtexte. Angemeldet bietet „Material hinzufügen“ eines Lernsets „Aus Jellyfin“ an.",
"jellyfinPrivacy": "Die Adresse deines Servers und deine Anmeldung bleiben auf diesem Gerät, und was du mit einem Lernset verbindest, folgt dir als IDs, nie als Titel. Um die Wörter einer Zeile zu finden, fragt die App die Instanz nach ihrem Text; sie antwortet und speichert ihn nicht. Wörter, die du hinzufügst, bekommen Tags, wo du ihnen begegnet bist, gespeichert mit deinen Karteikarten wie jeder Tag.",

View file

@ -2111,6 +2111,44 @@
"@planStepsDone": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
"planNothing": "Nothing due right now.",
"planLearningPaths": "Learning paths",
"examPrepare": "Prepare for an exam",
"examPrepareHint": "Choose what it covers and its date, and get a pace that gets you there.",
"examChange": "Change the exam",
"examDaysLeft": "{count, plural, =0{Today, {date}} =1{Tomorrow, {date}} other{In {count} days, {date}}}",
"@examDaysLeft": {"placeholders": {"count": {"type": "int"}, "date": {"type": "String"}}},
"examPast": "It was on {date}",
"@examPast": {"placeholders": {"date": {"type": "String"}}},
"examPlanGone": "This exam plan is gone",
"examCardsStarted": "{total, plural, =1{{started} of 1 flashcard started} other{{started} of {total} flashcards started}}",
"@examCardsStarted": {"placeholders": {"started": {"type": "int"}, "total": {"type": "int"}}},
"examPagesRead": "{total, plural, =1{{read} of 1 page read} other{{read} of {total} pages read}}",
"@examPagesRead": {"placeholders": {"read": {"type": "int"}, "total": {"type": "int"}}},
"examPaceTitle": "Your pace",
"examPaceNew": "{count, plural, =1{1 new flashcard a day} other{{count} new flashcards a day}}",
"@examPaceNew": {"placeholders": {"count": {"type": "int"}}},
"examPaceSteps": "{count, plural, =1{1 step a week} other{{count} steps a week}}",
"@examPaceSteps": {"placeholders": {"count": {"type": "int"}}},
"examPacePages": "{count, plural, =1{1 page a day} other{{count} pages a day}}",
"@examPacePages": {"placeholders": {"count": {"type": "int"}}},
"examPaceDone": "Everything new is learnt: review until the exam.",
"examStudy": "Study for the exam",
"examSheetHelp": "Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.",
"examDate": "Exam date",
"examSheetPlans": "This studyset’s exam plans",
"examSheetFromTags": "From the level tags",
"examSheetNoPlans": "No exam plans yet. Make one from this studyset’s topics and tags.",
"examRemove": "Stop preparing",
"examPlanTopicCount": "{count, plural, =1{1 topic} other{{count} topics}}",
"@examPlanTopicCount": {"placeholders": {"count": {"type": "int"}}},
"examPlanNew": "New exam plan",
"examPlanEdit": "Edit exam plan",
"examPlanHelp": "An exam plan names what an exam covers: whole topics, with their flashcards, learning paths and PDFs, and flashcards with certain tags. It is published with the studyset.",
"examPlanName": "Name",
"examPlanNameHint": "e.g. Final exam",
"examPlanTopics": "Topics it covers whole",
"examPlanTags": "Flashcards with these tags",
"examPlanEmpty": "This studyset has no topics or tagged flashcards to choose from yet.",
"examPlanDelete": "Delete plan",
"jellyfinTitle": "Jellyfin",
"jellyfinSubtitle": "Watch shows and films and listen to music from your own Jellyfin server, and learn the words of their subtitles and lyrics. Signed in, a studyset's Add material offers From Jellyfin.",
"jellyfinPrivacy": "Your server's address and your sign-in stay on this device, and what you tie to a studyset follows you as ids, never titles. To find the words of a line, the app asks the instance about its text, which it answers and doesn't keep. Words you add are tagged with where you met them, kept with your flashcards like any tag.",

View file

@ -10563,6 +10563,186 @@ abstract class AppLocalizations {
/// **'Learning paths'**
String get planLearningPaths;
/// No description provided for @examPrepare.
///
/// In en, this message translates to:
/// **'Prepare for an exam'**
String get examPrepare;
/// No description provided for @examPrepareHint.
///
/// In en, this message translates to:
/// **'Choose what it covers and its date, and get a pace that gets you there.'**
String get examPrepareHint;
/// No description provided for @examChange.
///
/// In en, this message translates to:
/// **'Change the exam'**
String get examChange;
/// No description provided for @examDaysLeft.
///
/// In en, this message translates to:
/// **'{count, plural, =0{Today, {date}} =1{Tomorrow, {date}} other{In {count} days, {date}}}'**
String examDaysLeft(int count, String date);
/// No description provided for @examPast.
///
/// In en, this message translates to:
/// **'It was on {date}'**
String examPast(String date);
/// No description provided for @examPlanGone.
///
/// In en, this message translates to:
/// **'This exam plan is gone'**
String get examPlanGone;
/// No description provided for @examCardsStarted.
///
/// In en, this message translates to:
/// **'{total, plural, =1{{started} of 1 flashcard started} other{{started} of {total} flashcards started}}'**
String examCardsStarted(int started, int total);
/// No description provided for @examPagesRead.
///
/// In en, this message translates to:
/// **'{total, plural, =1{{read} of 1 page read} other{{read} of {total} pages read}}'**
String examPagesRead(int read, int total);
/// No description provided for @examPaceTitle.
///
/// In en, this message translates to:
/// **'Your pace'**
String get examPaceTitle;
/// No description provided for @examPaceNew.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 new flashcard a day} other{{count} new flashcards a day}}'**
String examPaceNew(int count);
/// No description provided for @examPaceSteps.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 step a week} other{{count} steps a week}}'**
String examPaceSteps(int count);
/// No description provided for @examPacePages.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 page a day} other{{count} pages a day}}'**
String examPacePages(int count);
/// No description provided for @examPaceDone.
///
/// In en, this message translates to:
/// **'Everything new is learnt: review until the exam.'**
String get examPaceDone;
/// No description provided for @examStudy.
///
/// In en, this message translates to:
/// **'Study for the exam'**
String get examStudy;
/// No description provided for @examSheetHelp.
///
/// In en, this message translates to:
/// **'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.'**
String get examSheetHelp;
/// No description provided for @examDate.
///
/// In en, this message translates to:
/// **'Exam date'**
String get examDate;
/// No description provided for @examSheetPlans.
///
/// In en, this message translates to:
/// **'This studyset’s exam plans'**
String get examSheetPlans;
/// No description provided for @examSheetFromTags.
///
/// In en, this message translates to:
/// **'From the level tags'**
String get examSheetFromTags;
/// No description provided for @examSheetNoPlans.
///
/// In en, this message translates to:
/// **'No exam plans yet. Make one from this studyset’s topics and tags.'**
String get examSheetNoPlans;
/// No description provided for @examRemove.
///
/// In en, this message translates to:
/// **'Stop preparing'**
String get examRemove;
/// No description provided for @examPlanTopicCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 topic} other{{count} topics}}'**
String examPlanTopicCount(int count);
/// No description provided for @examPlanNew.
///
/// In en, this message translates to:
/// **'New exam plan'**
String get examPlanNew;
/// No description provided for @examPlanEdit.
///
/// In en, this message translates to:
/// **'Edit exam plan'**
String get examPlanEdit;
/// No description provided for @examPlanHelp.
///
/// In en, this message translates to:
/// **'An exam plan names what an exam covers: whole topics, with their flashcards, learning paths and PDFs, and flashcards with certain tags. It is published with the studyset.'**
String get examPlanHelp;
/// No description provided for @examPlanName.
///
/// In en, this message translates to:
/// **'Name'**
String get examPlanName;
/// No description provided for @examPlanNameHint.
///
/// In en, this message translates to:
/// **'e.g. Final exam'**
String get examPlanNameHint;
/// No description provided for @examPlanTopics.
///
/// In en, this message translates to:
/// **'Topics it covers whole'**
String get examPlanTopics;
/// No description provided for @examPlanTags.
///
/// In en, this message translates to:
/// **'Flashcards with these tags'**
String get examPlanTags;
/// No description provided for @examPlanEmpty.
///
/// In en, this message translates to:
/// **'This studyset has no topics or tagged flashcards to choose from yet.'**
String get examPlanEmpty;
/// No description provided for @examPlanDelete.
///
/// In en, this message translates to:
/// **'Delete plan'**
String get examPlanDelete;
/// No description provided for @jellyfinTitle.
///
/// In en, this message translates to:

View file

@ -6677,6 +6677,161 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get planLearningPaths => 'Lernpfade';
@override
String get examPrepare => 'Auf eine Prüfung vorbereiten';
@override
String get examPrepareHint =>
'Wähle, was sie abdeckt und wann sie ist, und bekomme ein Tempo, das dich hinbringt.';
@override
String get examChange => 'Prüfung ändern';
@override
String examDaysLeft(int count, String date) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'In $count Tagen, $date',
one: 'Morgen, $date',
zero: 'Heute, $date',
);
return '$_temp0';
}
@override
String examPast(String date) {
return 'Sie war am $date';
}
@override
String get examPlanGone => 'Diesen Prüfungsplan gibt es nicht mehr';
@override
String examCardsStarted(int started, int total) {
String _temp0 = intl.Intl.pluralLogic(
total,
locale: localeName,
other: '$started von $total Karteikarten begonnen',
one: '$started von 1 Karteikarte begonnen',
);
return '$_temp0';
}
@override
String examPagesRead(int read, int total) {
String _temp0 = intl.Intl.pluralLogic(
total,
locale: localeName,
other: '$read von $total Seiten gelesen',
one: '$read von 1 Seite gelesen',
);
return '$_temp0';
}
@override
String get examPaceTitle => 'Dein Tempo';
@override
String examPaceNew(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count neue Karteikarten am Tag',
one: '1 neue Karteikarte am Tag',
);
return '$_temp0';
}
@override
String examPaceSteps(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Schritte pro Woche',
one: '1 Schritt pro Woche',
);
return '$_temp0';
}
@override
String examPacePages(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Seiten am Tag',
one: '1 Seite am Tag',
);
return '$_temp0';
}
@override
String get examPaceDone =>
'Alles Neue ist gelernt: bis zur Prüfung wiederholen.';
@override
String get examStudy => 'Für die Prüfung lernen';
@override
String get examSheetHelp =>
'Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.';
@override
String get examDate => 'Prüfungsdatum';
@override
String get examSheetPlans => 'Prüfungspläne dieses Lernsets';
@override
String get examSheetFromTags => 'Aus den Stufen-Tags';
@override
String get examSheetNoPlans =>
'Noch keine Prüfungspläne. Erstelle einen aus den Themen und Tags dieses Lernsets.';
@override
String get examRemove => 'Nicht mehr vorbereiten';
@override
String examPlanTopicCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Themen',
one: '1 Thema',
);
return '$_temp0';
}
@override
String get examPlanNew => 'Neuer Prüfungsplan';
@override
String get examPlanEdit => 'Prüfungsplan bearbeiten';
@override
String get examPlanHelp =>
'Ein Prüfungsplan nennt, was eine Prüfung abdeckt: ganze Themen mit ihren Karteikarten, Lernpfaden und PDFs, und Karteikarten mit bestimmten Tags. Er wird mit dem Lernset veröffentlicht.';
@override
String get examPlanName => 'Name';
@override
String get examPlanNameHint => 'z. B. Abschlussprüfung';
@override
String get examPlanTopics => 'Themen, die sie ganz abdeckt';
@override
String get examPlanTags => 'Karteikarten mit diesen Tags';
@override
String get examPlanEmpty =>
'Dieses Lernset hat noch keine Themen oder Karteikarten mit Tags zur Auswahl.';
@override
String get examPlanDelete => 'Plan löschen';
@override
String get jellyfinTitle => 'Jellyfin';

View file

@ -6617,6 +6617,160 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get planLearningPaths => 'Learning paths';
@override
String get examPrepare => 'Prepare for an exam';
@override
String get examPrepareHint =>
'Choose what it covers and its date, and get a pace that gets you there.';
@override
String get examChange => 'Change the exam';
@override
String examDaysLeft(int count, String date) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'In $count days, $date',
one: 'Tomorrow, $date',
zero: 'Today, $date',
);
return '$_temp0';
}
@override
String examPast(String date) {
return 'It was on $date';
}
@override
String get examPlanGone => 'This exam plan is gone';
@override
String examCardsStarted(int started, int total) {
String _temp0 = intl.Intl.pluralLogic(
total,
locale: localeName,
other: '$started of $total flashcards started',
one: '$started of 1 flashcard started',
);
return '$_temp0';
}
@override
String examPagesRead(int read, int total) {
String _temp0 = intl.Intl.pluralLogic(
total,
locale: localeName,
other: '$read of $total pages read',
one: '$read of 1 page read',
);
return '$_temp0';
}
@override
String get examPaceTitle => 'Your pace';
@override
String examPaceNew(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count new flashcards a day',
one: '1 new flashcard a day',
);
return '$_temp0';
}
@override
String examPaceSteps(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count steps a week',
one: '1 step a week',
);
return '$_temp0';
}
@override
String examPacePages(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count pages a day',
one: '1 page a day',
);
return '$_temp0';
}
@override
String get examPaceDone => 'Everything new is learnt: review until the exam.';
@override
String get examStudy => 'Study for the exam';
@override
String get examSheetHelp =>
'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.';
@override
String get examDate => 'Exam date';
@override
String get examSheetPlans => 'This studyset’s exam plans';
@override
String get examSheetFromTags => 'From the level tags';
@override
String get examSheetNoPlans =>
'No exam plans yet. Make one from this studyset’s topics and tags.';
@override
String get examRemove => 'Stop preparing';
@override
String examPlanTopicCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count topics',
one: '1 topic',
);
return '$_temp0';
}
@override
String get examPlanNew => 'New exam plan';
@override
String get examPlanEdit => 'Edit exam plan';
@override
String get examPlanHelp =>
'An exam plan names what an exam covers: whole topics, with their flashcards, learning paths and PDFs, and flashcards with certain tags. It is published with the studyset.';
@override
String get examPlanName => 'Name';
@override
String get examPlanNameHint => 'e.g. Final exam';
@override
String get examPlanTopics => 'Topics it covers whole';
@override
String get examPlanTags => 'Flashcards with these tags';
@override
String get examPlanEmpty =>
'This studyset has no topics or tagged flashcards to choose from yet.';
@override
String get examPlanDelete => 'Delete plan';
@override
String get jellyfinTitle => 'Jellyfin';

View file

@ -27,6 +27,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -227,6 +228,37 @@ class CopyUpdater(
);
}
// An exam plan's topics are studysets of the release: the plans name
// the copy's own in their place.
final studysetOf = {
for (final MapEntry(key: item, value: studyset) in copy.studysets.entries)
item: studyset.id,
};
final ours = studysetOf.values.toSet();
for (final studyset in await _studysets.all()) {
final plans = studyset.content.examPlans;
if (!ours.contains(studyset.id) || plans.isEmpty) {
continue;
}
final renamed = [
for (final plan in plans)
ExamPlan(
id: plan.id,
name: plan.name,
topics: [
for (final topic in plan.topics) studysetOf[topic] ?? topic,
],
tags: plan.tags,
),
];
if (renamed.indexed.any((each) => each.$2 != plans[each.$1])) {
await _studysets.updateContent(
studyset.id,
studyset.content.withExamPlans(renamed),
);
}
}
// Cards.
for (final entry in diff.changed.where(_is(ReleaseKind.card))) {
final card = copy.cards[entry.id]!;
@ -578,6 +610,8 @@ class CopyUpdater(
bibliography: content.bibliography,
published: origin,
publishedAt: location,
examPlans: content.examPlans,
exam: content.exam,
);
static LearningPathContent _pathWithOrigin(

View file

@ -179,6 +179,25 @@ class ReleaseCopier(
},
);
// An exam plan's topics are studysets of the release too: its plans
// name the ones made of them now.
for (final MapEntry(key: directory, value: item)
in named.studysets.entries) {
if (item.content.examPlans.isNotEmpty) {
final content = item.content;
await _studysets.updateContent(
studysetIds[directory]!,
origin == null
? content
: _withOrigin(
content,
origin(item.id),
location: directory.isEmpty ? location : null,
),
);
}
}
final tagIds = <String, String>{};
Future<Set<String>> tagsOf(List<String> names) async => {
for (final name in names) tagIds[name] ??= await _tags.create(name),
@ -367,8 +386,8 @@ class ReleaseCopier(
static const _kinds = {'cards', 'notes', 'paths', 'pdfs'};
/// [json] with every studyset a study button names by the one [names]
/// gives in its place.
/// [json] with every studyset a study button or an exam plan names by the
/// one [names] gives in its place.
static Object? _renamed(Object? json, Map<String, String> names) =>
names.isEmpty
? json
@ -380,6 +399,11 @@ class ReleaseCopier(
for (final MapEntry(:key, :value) in map.entries)
key: switch ((key, value)) {
(studyButtonStudysetKey, final String id) => names[id] ?? id,
// The topics an exam plan covers.
('topics', final List<Object?> ids) => [
for (final id in ids)
if (id is String) names[id] ?? id else id,
],
_ => _renamed(value, names),
},
},
@ -421,6 +445,8 @@ class ReleaseCopier(
bibliography: content.bibliography,
published: origin,
publishedAt: location,
examPlans: content.examPlans,
exam: content.exam,
);
static LearningPathContent _pathWithOrigin(

View file

@ -21,7 +21,9 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/vocabulary_quiz.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/exam_coverage.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -361,6 +363,11 @@ class StudySessionBloc(
/// by the settings. Takes each one's study options along: its own, else
/// the settings' new cards a day.
Future<List<List<Card>>> _groups({bool filtered = true}) async {
if (_focus?.exam ?? false) {
if (await _examGroups() case final groups?) {
return groups;
}
}
final tagged = filtered ? await _cardsWithChosenTags() : null;
if (!filtered) {
_tagFilterCount = 0;
@ -402,6 +409,79 @@ class StudySessionBloc(
return groups;
}
/// For a session studying for the exam the person prepares for in the
/// studyset: what the exam's plan covers, as one group whose new cards
/// a day are the pace that gets them through by the exam (`examPace`);
/// null without an exam, or with one its studyset no longer plans for.
Future<List<List<Card>>?> _examGroups() async {
final all = await _studysets.all();
final choice = all
.where((each) => each.id == _studysetId)
.firstOrNull
?.content
.exam;
if (choice == null) {
return null;
}
final coverage = ExamCoverage(
studysets: _studysets,
cards: _cards,
tags: _tags,
);
final plan = await coverage.planOf(_studysetId, choice.plan);
if (plan == null) {
return null;
}
final covered = [
for (final each in await coverage.cardsOf(_studysetId, plan))
if (!_deletions.pending.contains(each.card.id)) each,
];
final schedules = await _reviews.schedulesOf([
for (final (studysetId: _, :card) in covered) card.id,
]);
final now = clock.now();
final startOfToday = startOfLocalDay(now);
// The pace as the day began: the new cards started today count among
// those it spreads, and against the day's limit.
final pace = examPace(
today: now,
examDate: choice.date,
newCards: covered.where((each) {
final schedule = schedules[each.card.id];
return (schedule?.isNew ?? true) ||
schedule?.firstReviewedAt?.isBefore(startOfToday) == false;
}).length,
stepsLeft: 0,
pagesLeft: 0,
);
_tagFilterCount = 0;
_groupOptions = [
// Reviews as many as are due ([StudysetStudyOptions.reviewsPerDay]'s
// default); no new cards left asks for no limit, which starts none.
StudysetStudyOptions(newPerDay: pace.newPerDay),
];
_order =
all
.where((each) => each.id == _studysetId)
.firstOrNull
?.content
.studyOptions
?.order ??
NewCardOrder.reviewsFirst;
_studysetOf.clear();
for (final (:studysetId, :card) in covered) {
_studysetOf[card.id] = studysetId;
}
_words = [
for (final (:studysetId, :card) in covered)
if (card.content.type == CardType.vocabulary)
(cardId: card.id, studysetId: studysetId, content: card.content),
];
return [
[for (final (studysetId: _, :card) in covered) card],
];
}
/// [ids] in the order the settings ask for.
List<String> _ordered(List<String> ids) => switch (_options.effectiveOrder) {
StudyOrder.oldestFirst => ids,

View file

@ -20,6 +20,10 @@ final class const StudyFocus({
/// Whether every card is gone through once, ratings moving none; else as
/// the settings have it.
final bool cram = false,
/// Whether only what the exam the person prepares for covers is studied,
/// its new cards at the pace that gets them through by the exam.
final bool exam = false,
}) with Equatable {
/// The focus [toQuery] wrote; null for a query without one, a session as
/// the settings have it.
@ -38,6 +42,7 @@ final class const StudyFocus({
_ => null,
},
cram: query[_cramKey] == '1',
exam: query[_examKey] == '1',
);
}
@ -48,6 +53,7 @@ final class const StudyFocus({
_topicsKey: topics ? '1' : '0',
if (backFirst case final backFirst?) _backFirstKey: backFirst ? '1' : '0',
if (cram) _cramKey: '1',
if (exam) _examKey: '1',
};
/// This focus asking the other side first.
@ -56,10 +62,11 @@ final class const StudyFocus({
topics: topics,
backFirst: !(backFirst ?? false),
cram: cram,
exam: exam,
);
@override
List<Object?> get props => [tag, topics, backFirst, cram];
List<Object?> get props => [tag, topics, backFirst, cram, exam];
}
const _focusKey = 'focus';
@ -68,3 +75,4 @@ const _tagNameKey = 'tagName';
const _topicsKey = 'topics';
const _backFirstKey = 'back';
const _cramKey = 'cram';
const _examKey = 'exam';

View file

@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/exam_coverage.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// The exams a studyset plans for and the one the person prepares for:
/// [plans] (its author's, then those offered from level tags; null until
/// read), the [choice] made, the [plan] chosen as it is now, and [progress]
/// with what it covers (null until worked out, or without a choice).
final class const ExamState({
final List<ExamPlan>? plans,
final ExamChoice? choice,
final ExamPlan? plan,
final ExamProgress? progress,
}) with Equatable {
@override
List<Object?> get props => [plans, choice, plan, progress];
}
/// The exam plans of the studyset [_studysetId] and the person's choice
/// among them (decided 2026-10-14): reads them as the studyset changes, and
/// how far the person has come with the plan chosen as its cards, reviews,
/// paths, their progress, PDFs and tags change; writes the choice and the
/// author's plans into the studyset.
class ExamCubit({
required final StudysetRepository _studysets,
required final CardRepository _cards,
required final TagRepository _tags,
required final ReviewRepository _reviews,
required final LearningPathRepository _paths,
required final PathProgressRepository _pathProgress,
required final PdfSourceRepository _pdfs,
required final String _studysetId,
}) extends Cubit<ExamState> {
this : super(const ExamState()) {
_subscriptions = [
_studysets.watchAll().listen((all) {
_studyset = all.where((each) => each.id == _studysetId).firstOrNull;
_refresh();
}, onError: (Object _) {}),
for (final changes in [
_cards.changes,
_reviews.changes,
_paths.changes,
_pathProgress.changes,
_pdfs.changes,
])
changes.listen((_) => _refresh()),
_tags.watch().listen((_) => _refresh(), onError: (Object _) {}),
];
}
late final _coverage = ExamCoverage(
studysets: _studysets,
cards: _cards,
tags: _tags,
);
late final List<StreamSubscription<Object?>> _subscriptions;
Studyset? _studyset;
/// Whether a reading runs, and whether another was asked for meanwhile:
/// a burst of changes reads once more, not once each.
var _reading = false;
var _readAgain = false;
void _refresh() {
if (_reading) {
_readAgain = true;
return;
}
unawaited(_read());
}
Future<void> _read() async {
_reading = true;
try {
do {
_readAgain = false;
final choice = _studyset?.content.exam;
final plans = await _coverage.plansOf(_studysetId);
final plan = switch (choice) {
final choice? =>
plans.where((plan) => plan.id == choice.plan).firstOrNull,
null => null,
};
final progress = plan == null
? null
: await _coverage.progressOf(
_studysetId,
plan,
reviews: _reviews,
paths: _paths,
pathProgress: _pathProgress,
pdfs: _pdfs,
);
if (isClosed) {
return;
}
emit(
ExamState(
plans: plans,
choice: choice,
plan: plan,
progress: progress,
),
);
} while (_readAgain && !isClosed);
} on Object catch (error) {
log('Reading the exam plans failed', name: 'Exam', error: error);
} finally {
_reading = false;
}
}
/// What a plan may cover: the studyset's topics (each with how deep it
/// sits) and the names of the tags its cards carry.
Future<({List<({Studyset studyset, int depth})> topics, List<String> tags})>
coverable() async => (
topics: await _coverage.topicsOf(_studysetId),
tags: await _coverage.tagNamesOf(_studysetId),
);
/// Prepares for the exam [planId] on [date]; none for null.
Future<void> choose(String? planId, DateTime? date) async {
final studyset = _studyset;
if (studyset == null) {
return;
}
await _studysets.updateContent(
_studysetId,
studyset.content.withExam(
planId == null || date == null
? null
: ExamChoice(
plan: planId,
date: DateTime(date.year, date.month, date.day),
),
),
);
}
/// Keeps [plan] among the author's plans, in place of the one with its id.
Future<void> savePlan(ExamPlan plan) async {
final studyset = _studyset;
if (studyset == null) {
return;
}
final plans = studyset.content.examPlans;
await _studysets.updateContent(
_studysetId,
studyset.content.withExamPlans(
plans.any((each) => each.id == plan.id)
? [
for (final each in plans)
if (each.id == plan.id) plan else each,
]
: [...plans, plan],
),
);
}
/// Removes the author's plan [planId], and the choice of it.
Future<void> deletePlan(String planId) async {
final studyset = _studyset;
if (studyset == null) {
return;
}
var content = studyset.content.withExamPlans([
for (final plan in studyset.content.examPlans)
if (plan.id != planId) plan,
]);
if (content.exam?.plan == planId) {
content = content.withExam(null);
}
await _studysets.updateContent(_studysetId, content);
}
@override
Future<void> close() async {
for (final subscription in _subscriptions) {
await subscription.cancel();
}
await super.close();
}
}

View file

@ -0,0 +1,195 @@
import 'package:clock/clock.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/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// How far a person has come with what an exam plan covers: its cards (and
/// how many of them were never studied, and how many were first studied
/// today), the steps of its learning paths (and how many are done), the
/// pages of its PDFs (and how many are read).
typedef ExamProgress = ({
int cards,
int newCards,
int startedToday,
int steps,
int stepsDone,
int pages,
int pagesRead,
});
/// What exam plans cover in a studyset (decided 2026-10-14), read from the
/// person's repositories: the studysets a plan takes whole (each with
/// everything it studies inside), and the cards of the plan's tags anywhere
/// in the studyset. The study session reads the cards alone ([cardsOf]),
/// the study plan everything ([progressOf]).
class ExamCoverage({
required final StudysetRepository _studysets,
required final CardRepository _cards,
required final TagRepository _tags,
}) {
/// The plans the studyset [studysetId] offers: its author's, then those
/// offered from the level tags its cards carry ([automaticExamPlans]).
Future<List<ExamPlan>> plansOf(String studysetId) async {
final all = await _studysets.all();
final studyset = all.where((each) => each.id == studysetId).firstOrNull;
if (studyset == null) {
return const [];
}
return [
...studyset.content.examPlans,
...automaticExamPlans(await tagNamesOf(studysetId)),
];
}
/// The names of the tags the cards the studyset [studysetId] studies
/// carry, in alphabetical order.
Future<List<String>> tagNamesOf(String studysetId) async {
final all = await _studysets.all();
final tagging = await _tags.read();
final names = <String>{};
for (final id in studiedIn(studysetId, all)) {
for (final card in await _cards.cardsOf(id)) {
names.addAll([for (final tag in tagging.of(card.id)) tag.name]);
}
}
return names.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
}
/// The topics a plan of the studyset [studysetId] may take whole: the
/// studysets inside it (not deleted), in tree order, each with how deep
/// it sits below it (1 for its children).
Future<List<({Studyset studyset, int depth})>> topicsOf(
String studysetId,
) async {
final all = notDeleted(await _studysets.all());
final topics = <({Studyset studyset, int depth})>[];
final seen = {studysetId};
void visit(String id, int depth) {
for (final child in childrenOf(id, all)) {
if (seen.add(child.id)) {
topics.add((studyset: child, depth: depth));
visit(child.id, depth + 1);
}
}
}
visit(studysetId, 1);
return topics;
}
/// The plan [planId] of the studyset [studysetId]; null for one it no
/// longer offers.
Future<ExamPlan?> planOf(String studysetId, String planId) async => [
for (final plan in await plansOf(studysetId))
if (plan.id == planId) plan,
].firstOrNull;
/// The studysets [plan] takes whole within [studysetId], each with what
/// it studies inside (archived ones rest).
Future<Set<String>> studysetsOf(String studysetId, ExamPlan plan) async {
final all = await _studysets.all();
final inside = subtreeOf(studysetId, all).toSet();
return {
for (final topic in plan.topics)
if (inside.contains(topic)) ...studiedIn(topic, all),
};
}
/// The cards [plan] covers in the studyset [studysetId], each with the
/// studyset it is in, each studyset's in its order: those of the
/// studysets it takes whole, and those of its tags anywhere the studyset
/// studies.
Future<List<({String studysetId, Card card})>> cardsOf(
String studysetId,
ExamPlan plan,
) async {
final all = await _studysets.all();
final whole = await studysetsOf(studysetId, plan);
final tagging = await _tags.read();
final tagged = <String>{
for (final name in plan.tags)
if (tagging.named(name) case final tag?) ...tagging.cardsWith(tag.id),
};
return [
for (final id in studiedIn(studysetId, all))
for (final card in await _cards.cardsOf(id))
if (whole.contains(id) || tagged.contains(card.id))
(studysetId: id, card: card),
];
}
/// How far the person has come with what [plan] covers in [studysetId]:
/// its cards by [reviews], its learning paths' steps by [pathProgress],
/// its PDFs' pages by how far each was read.
Future<ExamProgress> progressOf(
String studysetId,
ExamPlan plan, {
required ReviewRepository reviews,
required LearningPathRepository paths,
required PathProgressRepository pathProgress,
required PdfSourceRepository pdfs,
}) async {
final cards = [
for (final (studysetId: _, :card) in await cardsOf(studysetId, plan))
card,
];
final schedules = await reviews.schedulesOf([
for (final card in cards) card.id,
]);
final newCards = cards
.where((card) => schedules[card.id]?.isNew ?? true)
.length;
final startOfToday = startOfLocalDay(clock.now());
final startedToday = cards
.where(
(card) =>
schedules[card.id]?.firstReviewedAt?.isBefore(startOfToday) ==
false,
)
.length;
var steps = 0;
var stepsDone = 0;
var pages = 0;
var pagesRead = 0;
for (final id in await studysetsOf(studysetId, plan)) {
for (final path in await paths.pathsOf(id)) {
final pathSteps = await paths.stepsOf(path.id);
final progress = await pathProgress.of({
for (final step in pathSteps) step.id: step.kind,
});
steps += pathSteps.length;
stepsDone += pathSteps
.where((step) => progress[step.id]?.done ?? false)
.length;
}
final sources = await pdfs.sourcesOf(id);
final furthest = await pdfs.furthestPages(
pdfIds: {for (final source in sources) source.id},
);
for (final source in sources) {
final count = source.content.pageCount ?? 0;
pages += count;
pagesRead += (furthest[source.id] ?? 0).clamp(0, count);
}
}
return (
cards: cards.length,
newCards: newCards,
startedToday: startedToday,
steps: steps,
stepsDone: stepsDone,
pages: pages,
pagesRead: pagesRead,
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
// A studyset's content as its synced row stores it (see core/content/payload).
@ -19,10 +20,14 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
// copy's studyset came from (`published`, see published_ref.dart, with the
// published studyset's `location` on the copy's top studyset), left out for a
// studyset of its own. Schema 6 gave a source of the bibliography its kind
// (`kind`: `website` or `video`, left out for a book or an article).
// (`kind`: `website` or `video`, left out for a book or an article). Schema 7
// added the exams it plans for (`examPlans`: id, name, the topics it covers
// by their ids, the tags by their names; left out when none) and the one the
// person prepares for (`exam`: the plan's id and the exam's day,
// `YYYY-MM-DD`; theirs alone, never published).
/// The schema this app writes.
const studysetSchemaVersion = 6;
const studysetSchemaVersion = 7;
/// Encodes a studyset's content into its payload.
ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
@ -52,6 +57,9 @@ ByteData encodeStudysetPayload(StudysetContent content) => encodePayload(
...publishedRefToJson(published),
'location': ?content.publishedAt,
},
if (content.examPlans.isNotEmpty)
'examPlans': [for (final plan in content.examPlans) plan.toJson()],
if (content.exam case final exam?) 'exam': exam.toJson(),
},
);
@ -101,6 +109,12 @@ StudysetContent decodeStudysetPayload(ByteData payload) {
{'location': final String location} => location,
_ => null,
},
examPlans: [
for (final plan
in _optional<List<Object?>>(json, 'examPlans') ?? const [])
?ExamPlan.fromJson(plan),
],
exam: ExamChoice.fromJson(json['exam']),
);
}
throw const FormatException('Not a studyset payload.');

View file

@ -0,0 +1,186 @@
import 'dart:math' as math;
import 'package:equatable/equatable.dart';
/// What an exam covers, as a studyset plans for it (decided 2026-10-14): a
/// [name] ("JLPT N5"), the [topics] it takes whole (studysets inside the
/// one planning, with their cards, learning paths and PDFs) and the [tags]
/// of cards it takes from anywhere in it. Its author writes it, and it is
/// published with the studyset; plans are also offered from the studyset's
/// level tags ([automaticExamPlans]).
final class const ExamPlan({
/// Its id among the studyset's plans; `auto:…` for one offered from
/// level tags.
required final String id,
required final String name,
/// The studysets it covers whole, by their ids.
final List<String> topics = const [],
/// The tags whose cards it covers, by their names.
final List<String> tags = const [],
}) with Equatable {
/// Whether the app offers it from level tags rather than its author.
bool get automatic => id.startsWith(_automatic);
Map<String, Object?> toJson() => {
'id': id,
'name': name,
if (topics.isNotEmpty) 'topics': topics,
if (tags.isNotEmpty) 'tags': tags,
};
/// The plan [json] holds; null for one that isn't.
static ExamPlan? fromJson(Object? json) => switch (json) {
{'id': final String id, 'name': final String name} => ExamPlan(
id: id,
name: name,
topics: _strings(json['topics']),
tags: _strings(json['tags']),
),
_ => null,
};
@override
List<Object?> get props => [id, name, topics, tags];
}
/// The exam a person prepares for in a studyset, theirs alone (never
/// published, as a PDF's reading deadline): the [plan] by its id and the
/// exam's [date], a day of the person's calendar.
final class const ExamChoice({
required final String plan,
/// The exam's day, at midnight, local.
required final DateTime date,
}) with Equatable {
Map<String, Object?> toJson() => {
'plan': plan,
'date':
'${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}',
};
/// The choice [json] holds; null for one that isn't.
static ExamChoice? fromJson(Object? json) => switch (json) {
{'plan': final String plan, 'date': final String date} =>
switch (DateTime.tryParse(date)) {
final day? => ExamChoice(
plan: plan,
date: DateTime(day.year, day.month, day.day),
),
null => null,
},
_ => null,
};
@override
List<Object?> get props => [plan, date];
}
const _automatic = 'auto:';
/// The levels exams test, each with the tags its cards carry, easiest
/// first: JLPT's (N5 to N1), HSK's (1 to 7–9) and KRDict's (초급 to 고급).
const _examLevels = [
(
prefix: 'jlpt',
levels: [
('N5', 'JLPT-N5'),
('N4', 'JLPT-N4'),
('N3', 'JLPT-N3'),
('N2', 'JLPT-N2'),
('N1', 'JLPT-N1'),
],
exam: 'JLPT',
),
(
prefix: 'hsk',
levels: [
('1', 'HSK-1'),
('2', 'HSK-2'),
('3', 'HSK-3'),
('4', 'HSK-4'),
('5', 'HSK-5'),
('6', 'HSK-6'),
('7–9', 'HSK-7-9'),
],
exam: 'HSK',
),
(
prefix: 'topik',
levels: [('초급', '초급'), ('중급', '중급'), ('고급', '고급')],
exam: 'Korean',
),
];
/// The plans offered from the level tags a studyset's cards carry
/// ([tagNames], whatever their case): one for each level an exam tests,
/// taking the levels below it too, as an exam does (JLPT N4 takes N5's
/// words), up to the highest level present.
List<ExamPlan> automaticExamPlans(Iterable<String> tagNames) {
final present = {for (final name in tagNames) name.toLowerCase()};
return [
for (final (:prefix, :levels, :exam) in _examLevels)
for (final (index, (level, _)) in levels.indexed)
if (levels
.skip(index)
.any((each) => present.contains(each.$2.toLowerCase())))
ExamPlan(
id: '$_automatic$prefix:${index + 1}',
name: '$exam $level',
tags: [
for (final (_, tag) in levels.take(index + 1))
if (present.contains(tag.toLowerCase())) tag,
],
),
];
}
/// How far a person has come with what an exam covers, and the pace that
/// gets them through it by the exam: the days left, the new cards to learn
/// a day, the steps of learning paths to work through a week and the pages
/// to read a day, so that what is new is learnt a week before the exam (or
/// by it, closer than that), leaving the last week for reviews.
typedef ExamPace = ({
int daysLeft,
int newPerDay,
int stepsPerWeek,
int pagesPerDay,
});
/// The pace from [today] to the exam on [examDate] for [newCards] cards
/// never studied, [stepsLeft] steps not done and [pagesLeft] pages not
/// read (see [ExamPace]).
ExamPace examPace({
required DateTime today,
required DateTime examDate,
required int newCards,
required int stepsLeft,
required int pagesLeft,
}) {
final start = DateTime(today.year, today.month, today.day);
final end = DateTime(examDate.year, examDate.month, examDate.day);
// Whole days between, as the calendar counts them, summer time or not.
final daysLeft = math.max(
0,
(end.difference(start).inHours / 24).round(),
);
final learning = math.max(1, daysLeft > 14 ? daysLeft - 7 : daysLeft);
int perDay(int left) => left <= 0 ? 0 : (left / learning).ceil();
return (
daysLeft: daysLeft,
newPerDay: perDay(newCards),
stepsPerWeek: stepsLeft <= 0
? 0
: (stepsLeft / math.max(1, learning / 7)).ceil(),
pagesPerDay: perDay(pagesLeft),
);
}
List<String> _strings(Object? json) => [
if (json is List)
for (final item in json)
if (item is String) item,
];

View file

@ -3,6 +3,7 @@ import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
/// What a person writes about a studyset: everything its synced payload
/// holds. Where it sits and whether it is starred or archived are columns of
@ -55,6 +56,13 @@ final class const StudysetContent({
/// (`halkyon.eu/@raisondetredev/japanese`), on the copy's top studyset;
/// null on the others and on one of the person's own.
final String? publishedAt,
/// The exams it plans for, as its author wrote them (published with it).
final List<ExamPlan> examPlans = const [],
/// The exam the person prepares for and its date, theirs alone (never
/// published); null without one.
final ExamChoice? exam,
}) with Equatable {
/// Content with only a [title] and an empty description, as a new
/// studyset starts.
@ -76,6 +84,8 @@ final class const StudysetContent({
bibliography,
published,
publishedAt,
examPlans,
exam,
];
/// This content with a new [title] or [description]. The other fields can
@ -95,6 +105,8 @@ final class const StudysetContent({
bibliography: bibliography,
published: published,
publishedAt: publishedAt,
examPlans: examPlans,
exam: exam,
);
/// This content studied with [options]; null for the defaults.
@ -113,6 +125,8 @@ final class const StudysetContent({
bibliography: bibliography,
published: published,
publishedAt: publishedAt,
examPlans: examPlans,
exam: exam,
);
/// This content citing [bibliography].
@ -131,5 +145,45 @@ final class const StudysetContent({
bibliography: bibliography,
published: published,
publishedAt: publishedAt,
examPlans: examPlans,
exam: exam,
);
/// This content planning for the exams [plans].
StudysetContent withExamPlans(List<ExamPlan> plans) => StudysetContent(
title: title,
description: description,
secondaryTitle: secondaryTitle,
colorName: colorName,
license: license,
licenseHolder: licenseHolder,
licenseYear: licenseYear,
frontLanguage: frontLanguage,
backLanguage: backLanguage,
studyOptions: studyOptions,
bibliography: bibliography,
published: published,
publishedAt: publishedAt,
examPlans: plans,
exam: exam,
);
/// This content with the person preparing for [exam]; null for none.
StudysetContent withExam(ExamChoice? exam) => StudysetContent(
title: title,
description: description,
secondaryTitle: secondaryTitle,
colorName: colorName,
license: license,
licenseHolder: licenseHolder,
licenseYear: licenseYear,
frontLanguage: frontLanguage,
backLanguage: backLanguage,
studyOptions: studyOptions,
bibliography: bibliography,
published: published,
publishedAt: publishedAt,
examPlans: examPlans,
exam: exam,
);
}

View file

@ -0,0 +1,550 @@
import 'dart:math' as math;
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_focus.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The exam the person prepares for in the studyset [studysetId], at the
/// top of its study plan (decided 2026-10-14). Without one, a row to
/// prepare for one; with one, its name and how many days are left, how far
/// the person has come with what it covers (flashcards started, steps
/// done, pages read), the pace that gets them through by the exam, and a
/// button to study for it: what it covers alone, its new cards at that
/// pace.
class const ExamSection({required final String studysetId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final state = context.watch<ExamCubit>().state;
if (state.plans == null) {
return const SizedBox.shrink();
}
void change() => _chooseExam(context);
final choice = state.choice;
if (choice == null) {
return ListTile(
tileColor: colors.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
leading: Icon(LucideIcons.graduationCap, color: colors.primary),
title: Text(l10n.examPrepare),
subtitle: Text(l10n.examPrepareHint),
trailing: const Icon(LucideIcons.chevronRight),
onTap: change,
);
}
final muted = theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
);
final plan = state.plan;
final progress = state.progress;
final today = clock.now();
final date = MaterialLocalizations.of(context).formatMediumDate(
choice.date,
);
final pace = progress == null
? null
: examPace(
today: today,
examDate: choice.date,
newCards: progress.newCards + progress.startedToday,
stepsLeft: progress.steps - progress.stepsDone,
pagesLeft: progress.pages - progress.pagesRead,
);
final past = choice.date.isBefore(
DateTime(today.year, today.month, today.day),
);
return DecoratedBox(
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 4, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.graduationCap, color: colors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
plan?.name ?? l10n.examPlanGone,
style: theme.textTheme.titleMedium,
),
Text(
past
? l10n.examPast(date)
: l10n.examDaysLeft(pace?.daysLeft ?? 0, date),
style: muted,
),
],
),
),
IconButton(
icon: const Icon(LucideIcons.pencil),
tooltip: l10n.examChange,
onPressed: change,
),
],
),
if (plan != null && progress != null && pace != null)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
_Bar(
done: progress.cards - progress.newCards,
total: progress.cards,
label: l10n.examCardsStarted(
progress.cards - progress.newCards,
progress.cards,
),
),
if (progress.steps > 0)
_Bar(
done: progress.stepsDone,
total: progress.steps,
label: l10n.planStepsDone(
progress.stepsDone,
progress.steps,
),
),
if (progress.pages > 0)
_Bar(
done: progress.pagesRead,
total: progress.pages,
label: l10n.examPagesRead(
progress.pagesRead,
progress.pages,
),
),
const SizedBox(height: 8),
Text(l10n.examPaceTitle, style: theme.textTheme.labelLarge),
const SizedBox(height: 2),
Text(
switch ([
if (pace.newPerDay > 0)
l10n.examPaceNew(pace.newPerDay),
if (pace.stepsPerWeek > 0)
l10n.examPaceSteps(pace.stepsPerWeek),
if (pace.pagesPerDay > 0)
l10n.examPacePages(pace.pagesPerDay),
]) {
final parts when parts.isEmpty => l10n.examPaceDone,
final parts => parts.join(' · '),
},
style: muted,
),
if (progress.cards > 0) ...[
const SizedBox(height: 12),
FilledButton.tonalIcon(
icon: const Icon(LucideIcons.layers),
label: Text(l10n.examStudy),
onPressed: () => context.push(
RoutePaths.study(
studysetId,
query: const StudyFocus(exam: true).toQuery(),
),
),
),
],
],
),
),
],
),
),
);
}
}
/// How far something has come: a bar, then [label].
class const _Bar({
required final int done,
required final int total,
required final String label,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: total == 0 ? 0 : done / total,
minHeight: 4,
borderRadius: BorderRadius.circular(2),
backgroundColor: colors.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
);
}
}
/// Asks which exam to prepare for and when it is, and lets the studyset's
/// own plans be made, changed and deleted on the way.
Future<void> _chooseExam(BuildContext context) {
final cubit = context.read<ExamCubit>();
return showAdaptiveSheet<void>(
context,
builder: (context) =>
BlocProvider.value(value: cubit, child: const _ExamSheet()),
);
}
class const _ExamSheet() extends StatefulWidget {
@override
State<_ExamSheet> createState() => _ExamSheetState();
}
class _ExamSheetState() extends State<_ExamSheet> {
late final _today = clock.now();
late final ExamChoice? _current = context.read<ExamCubit>().state.choice;
late String? _planId =
_current?.plan ?? context.read<ExamCubit>().state.plans?.firstOrNull?.id;
/// Twelve weeks from today unless chosen: an exam is months off, as a
/// rule, rather than days.
late DateTime _date =
_current?.date ??
DateTime(_today.year, _today.month, _today.day + 7 * 12);
Future<void> _edit([ExamPlan? plan]) async {
final cubit = context.read<ExamCubit>();
final saved = await showAdaptiveSheet<String>(
context,
builder: (context) => BlocProvider.value(
value: cubit,
child: _PlanEditor(plan: plan),
),
);
if (saved != null && mounted) {
setState(() => _planId = saved);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final cubit = context.watch<ExamCubit>();
final plans = cubit.state.plans ?? const <ExamPlan>[];
final planId = plans.any((plan) => plan.id == _planId) ? _planId : null;
final heading = theme.textTheme.labelLarge?.copyWith(
color: colors.onSurfaceVariant,
);
final muted = theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
);
Widget option(ExamPlan plan) => RadioListTile<String>(
value: plan.id,
contentPadding: EdgeInsets.zero,
title: Text(plan.name),
subtitle: Text(
[
if (plan.topics.isNotEmpty)
l10n.examPlanTopicCount(plan.topics.length),
...plan.tags,
].join(' · '),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
secondary: plan.automatic
? null
: IconButton(
icon: const Icon(LucideIcons.pencil),
tooltip: l10n.examPlanEdit,
onPressed: () => _edit(plan),
),
);
final authored = [
for (final plan in plans)
if (!plan.automatic) plan,
];
final automatic = [
for (final plan in plans)
if (plan.automatic) plan,
];
return SheetScaffold(
title: l10n.examPrepare,
footer: Row(
children: [
if (_current != null)
TextButton(
style: TextButton.styleFrom(foregroundColor: colors.error),
onPressed: () async {
Navigator.of(context).pop();
await cubit.choose(null, null);
},
child: Text(l10n.examRemove),
),
const Spacer(),
FilledButton(
onPressed: planId == null
? null
: () async {
Navigator.of(context).pop();
await cubit.choose(planId, _date);
},
child: Text(l10n.commonSave),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.examSheetHelp, style: muted),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.calendarCheck),
title: Text(l10n.examDate),
subtitle: Text(
MaterialLocalizations.of(context).formatMediumDate(_date),
),
onTap: () async {
final first = DateTime(_today.year, _today.month, _today.day);
if (await showDatePicker(
context: context,
initialDate: _date.isBefore(first) ? first : _date,
firstDate: first,
lastDate: DateTime(
_today.year + 5,
_today.month,
_today.day,
),
)
case final picked?) {
setState(() => _date = picked);
}
},
),
const SizedBox(height: 12),
RadioGroup<String>(
groupValue: planId,
onChanged: (id) => setState(() => _planId = id),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (plans.isEmpty)
Text(l10n.examSheetNoPlans, style: muted)
else ...[
if (authored.isNotEmpty) ...[
Text(l10n.examSheetPlans, style: heading),
for (final plan in authored) option(plan),
],
if (automatic.isNotEmpty) ...[
if (authored.isNotEmpty) const SizedBox(height: 12),
Text(l10n.examSheetFromTags, style: heading),
for (final plan in automatic) option(plan),
],
],
],
),
),
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.examPlanNew),
onPressed: _edit,
),
),
],
),
);
}
}
/// Makes a plan of the studyset's own, or changes or deletes [plan]:
/// its name, the topics it takes whole and the tags whose flashcards it
/// takes. Completes with the id of the plan saved; null otherwise.
class const _PlanEditor({required final ExamPlan? plan})
extends StatefulWidget {
@override
State<_PlanEditor> createState() => _PlanEditorState();
}
class _PlanEditorState() extends State<_PlanEditor> {
late final _name = TextEditingController(text: widget.plan?.name);
late final _topics = {...?widget.plan?.topics};
late final _tags = {...?widget.plan?.tags};
late final Future<
({List<({Studyset studyset, int depth})> topics, List<String> tags})
>
_coverable = context.read<ExamCubit>().coverable();
@override
void dispose() {
_name.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final heading = theme.textTheme.labelLarge?.copyWith(
color: colors.onSurfaceVariant,
);
final muted = theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
);
final complete =
_name.text.trim().isNotEmpty &&
(_topics.isNotEmpty || _tags.isNotEmpty);
return SheetScaffold(
title: widget.plan == null ? l10n.examPlanNew : l10n.examPlanEdit,
footer: Row(
children: [
if (widget.plan case final plan?)
TextButton(
style: TextButton.styleFrom(foregroundColor: colors.error),
onPressed: () async {
final cubit = context.read<ExamCubit>();
Navigator.of(context).pop();
await cubit.deletePlan(plan.id);
},
child: Text(l10n.examPlanDelete),
),
const Spacer(),
FilledButton(
onPressed: !complete
? null
: () async {
final cubit = context.read<ExamCubit>();
final plan = ExamPlan(
id: widget.plan?.id ?? _newPlanId(),
name: _name.text.trim(),
topics: [..._topics],
tags: [..._tags],
);
Navigator.of(context).pop(plan.id);
await cubit.savePlan(plan);
},
child: Text(l10n.commonSave),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.examPlanHelp, style: muted),
const SizedBox(height: 16),
TextField(
controller: _name,
autofocus: widget.plan == null,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: l10n.examPlanName,
hintText: l10n.examPlanNameHint,
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
FutureBuilder(
future: _coverable,
builder: (context, snapshot) {
final coverable = snapshot.data;
if (coverable == null) {
return const SizedBox.shrink();
}
if (coverable.topics.isEmpty && coverable.tags.isEmpty) {
return Text(l10n.examPlanEmpty, style: muted);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (coverable.topics.isNotEmpty) ...[
Text(l10n.examPlanTopics, style: heading),
for (final (:studyset, :depth) in coverable.topics)
CheckboxListTile(
value: _topics.contains(studyset.id),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsetsDirectional.only(
start: 16.0 * math.max(0, depth - 1),
),
title: Text(
studyset.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onChanged: (on) => setState(
() => on ?? false
? _topics.add(studyset.id)
: _topics.remove(studyset.id),
),
),
const SizedBox(height: 12),
],
if (coverable.tags.isNotEmpty) ...[
Text(l10n.examPlanTags, style: heading),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final tag in coverable.tags)
FilterChip(
label: Text(tag),
selected: _tags.contains(tag),
onSelected: (on) => setState(
() => on ? _tags.add(tag) : _tags.remove(tag),
),
),
],
),
],
],
);
},
),
],
),
);
}
}
/// A new id for a plan among the studyset's own: unlikely to meet another,
/// and never `auto:…`.
String _newPlanId() =>
'e${clock.now().microsecondsSinceEpoch.toRadixString(36)}'
'${math.Random().nextInt(1 << 30).toRadixString(36)}';

View file

@ -35,6 +35,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activit
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/exam_section.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -980,12 +981,13 @@ class const _Sparkline({
oldDelegate.color != color || !listEquals(oldDelegate.values, values);
}
/// The studyset [studysetId]'s plan (decided 2026-10-13): how many of its
/// The studyset [studysetId]'s plan (decided 2026-10-13): the exam the
/// person prepares for in it ([ExamSection]), how many of its
/// learning paths are finished, what to do next (study the cards due, the
/// next step of each path under way, or of the first path not finished
/// when none is), and how far each path has come, each opening the path.
/// The paths themselves, to add, reorder and manage, are material; the
/// plan only refers to them, as it will to an exam's date.
/// plan only refers to them.
class const PlanPart({
required final String studysetId,
required final ScrollController scroll,
@ -1045,6 +1047,10 @@ class const PlanPart({
),
];
return _pullToSync(context, scroll, [
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
sliver: SliverToBoxAdapter(child: ExamSection(studysetId: studysetId)),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),

View file

@ -40,6 +40,7 @@ import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.da
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
@ -125,6 +126,19 @@ class const StudysetScreen({
),
// The content tab to show once material was added.
BlocProvider(create: (context) => ContentTabCubit()),
// The exams it plans for, read once the study plan shows.
BlocProvider(
create: (context) => ExamCubit(
studysets: person.studysets,
cards: person.cards,
tags: person.tags,
reviews: person.reviews,
paths: person.paths,
pathProgress: person.pathProgress,
pdfs: person.pdfSources,
studysetId: studyset.id,
),
),
],
child: MultiBlocListener(
listeners: [

View file

@ -196,6 +196,8 @@ class _StudysetSheetState() extends State<_StudysetSheet> {
bibliography: initial?.bibliography ?? const [],
published: initial?.published,
publishedAt: initial?.publishedAt,
examPlans: initial?.examPlans ?? const [],
exam: initial?.exam,
);
}

View file

@ -19,6 +19,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_rep
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
@ -45,11 +46,13 @@ final bookHash = sha256.convert(book).toString();
const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
/// Release [version] of a Japanese studyset, described as [description],
/// with [cards] (by id: front, back and tags) in it, a Kanji studyset in it
/// holding [kanjiCards], and a PDF titled [bookTitle].
/// planning for [examPlans], with [cards] (by id: front, back and tags) in
/// it, a Kanji studyset in it holding [kanjiCards], and a PDF titled
/// [bookTitle].
PublishedRelease japanese(
int version, {
String description = 'From N5 on.',
List<ExamPlan> examPlans = const [],
Map<String, (String, String, List<String>)> cards = const {},
Map<String, (String, String, List<String>)> kanjiCards = const {},
bool withKanji = true,
@ -84,6 +87,7 @@ PublishedRelease japanese(
title: 'Japanese',
description: RichContent.plain(description),
license: 'CC-BY-SA-4.0',
examPlans: examPlans,
),
),
),
@ -325,6 +329,35 @@ void main() {
expect((await cards.cardsOf(kanji.id)).single.content.front.plainText, '一');
});
test("exam plans the release brings name the copy's studysets; the exam "
'the learner prepares for stays theirs', () async {
final v1 = japanese(1);
final root = await copyOf(v1);
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
final exam = ExamChoice(plan: 'auto:jlpt:1', date: DateTime(2026, 12, 6));
await studysets.updateContent(root, copy.content.withExam(exam));
await updater.take(
root,
v1,
japanese(
2,
examPlans: const [
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: ['kanji']),
],
),
fileOf: fileOf,
);
final all = await studysets.all();
final now = all.singleWhere((s) => s.id == root);
final kanji = all.singleWhere((s) => s.parentId == root);
expect(now.content.examPlans, [
ExamPlan(id: 'kanji-test', name: 'Kanji test', topics: [kanji.id]),
]);
expect(now.content.exam, exam);
});
test("a studyset's or PDF's fields the learner changed stay theirs; the "
"copy says it holds the newer release; files it has aren't fetched "
'again', () async {

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