feat(explore): the Explore tab, and adding a published studyset as a copy

Explore lists what is published on the instance as the old app's feed:
search over titles and authors, filters for languages and kinds of
content, the newest release first, the next page only when asked for;
each studyset opens a summary with all of it. Anonymous people explore
too.

Add to library makes a copy in the app, as an import: through the
person's own repositories, the files among their own files, the tags by
name among their own tags, everything read first, so a release in a
schema this app doesn't know is refused whole. The server never writes
into a learner's space. Every item of a copy names where it came from
(publication, item, release: card schema 9, note 3, step 3, path 2,
PDF 2, studyset 5), merged per side like the rest, for later releases to
be taken from there.

The Publish sheet takes a studyset off Explore again, after asking.

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

View file

@ -95,7 +95,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries. - **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever. - **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared. - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **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. - **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. `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`; 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`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below). - **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. `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`; 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`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs 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): grid or detail (one card beside the list; 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; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, 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). - **Lists that can grow long are built as they scroll into view**: a studyset's tabs 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): grid or detail (one card beside the list; 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; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, 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).
@ -157,7 +157,8 @@ 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. - **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.
- **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. - **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; `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 as a form, for people with an identity. - **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; `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 as a form, for people with an identity.
- **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. - **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.
- **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 (stage 3, next). 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.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.

View file

@ -1396,5 +1396,40 @@
"publishUnreachable": "Das Veröffentlichen ist gerade nicht erreichbar.", "publishUnreachable": "Das Veröffentlichen ist gerade nicht erreichbar.",
"publishRetry": "Erneut versuchen", "publishRetry": "Erneut versuchen",
"publishCopyLocation": "Adresse kopieren", "publishCopyLocation": "Adresse kopieren",
"publishLocationCopied": "Adresse kopiert." "publishLocationCopied": "Adresse kopiert.",
"publishedField": "Veröffentlichung",
"publishedVersion": "v{version}",
"exploreSearch": "Titel und Namen durchsuchen",
"exploreLanguages": "Sprachen",
"exploreAnyLanguage": "Alle",
"exploreLanguagePair": "{front} → {back}",
"exploreKinds": "Inhalt",
"exploreKindsChosen": "{count, plural, =1{1 Art Inhalt} other{{count} Arten Inhalt}}",
"exploreKindCards": "Karteikarten",
"exploreKindNotes": "Notizen",
"exploreKindPaths": "Lernpfade",
"exploreKindPdfs": "PDFs",
"exploreKindsIntro": "Nur Lernsets, die all das enthalten.",
"exploreClearFilters": "Filter zurücksetzen",
"exploreNothingMatches": "Nichts Veröffentlichtes passt dazu.",
"exploreBy": "von {author}",
"exploreRelease": "v{version} · {date}",
"exploreLatestRelease": "Neueste Veröffentlichung: v{version} · {date}",
"exploreAddToLibrary": "Zur Bibliothek hinzufügen",
"exploreInLibrary": "In deiner Bibliothek",
"explorePercent": "{percent} %",
"exploreAdded": "Zu deiner Bibliothek hinzugefügt.",
"exploreOpen": "Öffnen",
"exploreNeedsNewerApp": "Dieses Lernset braucht eine neuere Version der App.",
"exploreFailed": "Deine Instanz war nicht erreichbar. Versuche es noch einmal.",
"exploreUnreachable": "Entdecken ist gerade nicht erreichbar.",
"exploreRetry": "Erneut versuchen",
"exploreMore": "Mehr anzeigen",
"exploreNoDescription": "Keine Beschreibung.",
"exploreReadLicence": "Lizenz lesen",
"publishWithdraw": "Aus Entdecken nehmen",
"publishWithdrawTitle": "Aus Entdecken nehmen?",
"publishWithdrawMessage": "Niemand kann es dann noch hinzufügen, und Kopien bekommen keine Aktualisierungen. Seine Veröffentlichungen bleiben, wie sie sind; veröffentlichst du es wieder, ist es zurück.",
"publishWithdrawn": "Zurückgezogen · Kopien bekommen keine Aktualisierungen",
"publishWithdrawnDone": "Aus Entdecken genommen."
} }

View file

@ -1628,5 +1628,47 @@
"publishUnreachable": "Publishing can't be reached right now.", "publishUnreachable": "Publishing can't be reached right now.",
"publishRetry": "Retry", "publishRetry": "Retry",
"publishCopyLocation": "Copy address", "publishCopyLocation": "Copy address",
"publishLocationCopied": "Address copied." "publishLocationCopied": "Address copied.",
"publishedField": "Release",
"publishedVersion": "v{version}",
"@publishedVersion": {"placeholders": {"version": {"type": "int"}}},
"exploreSearch": "Search titles and authors",
"exploreLanguages": "Languages",
"exploreAnyLanguage": "Any",
"exploreLanguagePair": "{front} → {back}",
"@exploreLanguagePair": {"placeholders": {"front": {"type": "String"}, "back": {"type": "String"}}},
"exploreKinds": "Content",
"exploreKindsChosen": "{count, plural, =1{1 kind of content} other{{count} kinds of content}}",
"@exploreKindsChosen": {"placeholders": {"count": {"type": "int"}}},
"exploreKindCards": "Flashcards",
"exploreKindNotes": "Notes",
"exploreKindPaths": "Learning paths",
"exploreKindPdfs": "PDFs",
"exploreKindsIntro": "Only studysets holding each of these.",
"exploreClearFilters": "Clear filters",
"exploreNothingMatches": "Nothing published here matches.",
"exploreBy": "by {author}",
"@exploreBy": {"placeholders": {"author": {"type": "String"}}},
"exploreRelease": "v{version} · {date}",
"@exploreRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"exploreLatestRelease": "Latest release: v{version} · {date}",
"@exploreLatestRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"exploreAddToLibrary": "Add to library",
"exploreInLibrary": "In your library",
"explorePercent": "{percent} %",
"@explorePercent": {"placeholders": {"percent": {"type": "int"}}},
"exploreAdded": "Added to your library.",
"exploreOpen": "Open",
"exploreNeedsNewerApp": "This studyset needs a newer version of the app.",
"exploreFailed": "Couldn't reach your instance. Try again.",
"exploreUnreachable": "Explore can't be reached right now.",
"exploreRetry": "Retry",
"exploreMore": "Show more",
"exploreNoDescription": "No description.",
"exploreReadLicence": "Read the licence",
"publishWithdraw": "Take off Explore",
"publishWithdrawTitle": "Take it off Explore?",
"publishWithdrawMessage": "Nobody can add it any more, and copies get no updates. Its releases stay as they are; publishing it again brings it back.",
"publishWithdrawn": "Withdrawn · copies get no updates",
"publishWithdrawnDone": "Taken off Explore."
} }

View file

@ -21,5 +21,6 @@
"licenseSummaryByNcSa": "Others may share and adapt it non-commercially, with credit and the same license on adaptations.", "licenseSummaryByNcSa": "Others may share and adapt it non-commercially, with credit and the same license on adaptations.",
"licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license.", "licenseSummaryByNcNd": "Others may share it non-commercially, with credit, but may not adapt it: the strictest Creative Commons license.",
"publishLicence": "License", "publishLicence": "License",
"publishLicenceNotOpen": "These studysets' licenses don't allow passing them on: {titles}" "publishLicenceNotOpen": "These studysets' licenses don't allow passing them on: {titles}",
"exploreReadLicence": "Read the license"
} }

View file

@ -8288,6 +8288,216 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Address copied.'** /// **'Address copied.'**
String get publishLocationCopied; String get publishLocationCopied;
/// No description provided for @publishedField.
///
/// In en, this message translates to:
/// **'Release'**
String get publishedField;
/// No description provided for @publishedVersion.
///
/// In en, this message translates to:
/// **'v{version}'**
String publishedVersion(int version);
/// No description provided for @exploreSearch.
///
/// In en, this message translates to:
/// **'Search titles and authors'**
String get exploreSearch;
/// No description provided for @exploreLanguages.
///
/// In en, this message translates to:
/// **'Languages'**
String get exploreLanguages;
/// No description provided for @exploreAnyLanguage.
///
/// In en, this message translates to:
/// **'Any'**
String get exploreAnyLanguage;
/// No description provided for @exploreLanguagePair.
///
/// In en, this message translates to:
/// **'{front} → {back}'**
String exploreLanguagePair(String front, String back);
/// No description provided for @exploreKinds.
///
/// In en, this message translates to:
/// **'Content'**
String get exploreKinds;
/// No description provided for @exploreKindsChosen.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 kind of content} other{{count} kinds of content}}'**
String exploreKindsChosen(int count);
/// No description provided for @exploreKindCards.
///
/// In en, this message translates to:
/// **'Flashcards'**
String get exploreKindCards;
/// No description provided for @exploreKindNotes.
///
/// In en, this message translates to:
/// **'Notes'**
String get exploreKindNotes;
/// No description provided for @exploreKindPaths.
///
/// In en, this message translates to:
/// **'Learning paths'**
String get exploreKindPaths;
/// No description provided for @exploreKindPdfs.
///
/// In en, this message translates to:
/// **'PDFs'**
String get exploreKindPdfs;
/// No description provided for @exploreKindsIntro.
///
/// In en, this message translates to:
/// **'Only studysets holding each of these.'**
String get exploreKindsIntro;
/// No description provided for @exploreClearFilters.
///
/// In en, this message translates to:
/// **'Clear filters'**
String get exploreClearFilters;
/// No description provided for @exploreNothingMatches.
///
/// In en, this message translates to:
/// **'Nothing published here matches.'**
String get exploreNothingMatches;
/// No description provided for @exploreBy.
///
/// In en, this message translates to:
/// **'by {author}'**
String exploreBy(String author);
/// No description provided for @exploreRelease.
///
/// In en, this message translates to:
/// **'v{version} · {date}'**
String exploreRelease(int version, String date);
/// No description provided for @exploreLatestRelease.
///
/// In en, this message translates to:
/// **'Latest release: v{version} · {date}'**
String exploreLatestRelease(int version, String date);
/// No description provided for @exploreAddToLibrary.
///
/// In en, this message translates to:
/// **'Add to library'**
String get exploreAddToLibrary;
/// No description provided for @exploreInLibrary.
///
/// In en, this message translates to:
/// **'In your library'**
String get exploreInLibrary;
/// No description provided for @explorePercent.
///
/// In en, this message translates to:
/// **'{percent} %'**
String explorePercent(int percent);
/// No description provided for @exploreAdded.
///
/// In en, this message translates to:
/// **'Added to your library.'**
String get exploreAdded;
/// No description provided for @exploreOpen.
///
/// In en, this message translates to:
/// **'Open'**
String get exploreOpen;
/// No description provided for @exploreNeedsNewerApp.
///
/// In en, this message translates to:
/// **'This studyset needs a newer version of the app.'**
String get exploreNeedsNewerApp;
/// No description provided for @exploreFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach your instance. Try again.'**
String get exploreFailed;
/// No description provided for @exploreUnreachable.
///
/// In en, this message translates to:
/// **'Explore can\'t be reached right now.'**
String get exploreUnreachable;
/// No description provided for @exploreRetry.
///
/// In en, this message translates to:
/// **'Retry'**
String get exploreRetry;
/// No description provided for @exploreMore.
///
/// In en, this message translates to:
/// **'Show more'**
String get exploreMore;
/// No description provided for @exploreNoDescription.
///
/// In en, this message translates to:
/// **'No description.'**
String get exploreNoDescription;
/// No description provided for @exploreReadLicence.
///
/// In en, this message translates to:
/// **'Read the licence'**
String get exploreReadLicence;
/// No description provided for @publishWithdraw.
///
/// In en, this message translates to:
/// **'Take off Explore'**
String get publishWithdraw;
/// No description provided for @publishWithdrawTitle.
///
/// In en, this message translates to:
/// **'Take it off Explore?'**
String get publishWithdrawTitle;
/// No description provided for @publishWithdrawMessage.
///
/// In en, this message translates to:
/// **'Nobody can add it any more, and copies get no updates. Its releases stay as they are; publishing it again brings it back.'**
String get publishWithdrawMessage;
/// No description provided for @publishWithdrawn.
///
/// In en, this message translates to:
/// **'Withdrawn · copies get no updates'**
String get publishWithdrawn;
/// No description provided for @publishWithdrawnDone.
///
/// In en, this message translates to:
/// **'Taken off Explore.'**
String get publishWithdrawnDone;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -5186,4 +5186,133 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get publishLocationCopied => 'Adresse kopiert.'; String get publishLocationCopied => 'Adresse kopiert.';
@override
String get publishedField => 'Veröffentlichung';
@override
String publishedVersion(int version) {
return 'v$version';
}
@override
String get exploreSearch => 'Titel und Namen durchsuchen';
@override
String get exploreLanguages => 'Sprachen';
@override
String get exploreAnyLanguage => 'Alle';
@override
String exploreLanguagePair(String front, String back) {
return '$front → $back';
}
@override
String get exploreKinds => 'Inhalt';
@override
String exploreKindsChosen(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Arten Inhalt',
one: '1 Art Inhalt',
);
return '$_temp0';
}
@override
String get exploreKindCards => 'Karteikarten';
@override
String get exploreKindNotes => 'Notizen';
@override
String get exploreKindPaths => 'Lernpfade';
@override
String get exploreKindPdfs => 'PDFs';
@override
String get exploreKindsIntro => 'Nur Lernsets, die all das enthalten.';
@override
String get exploreClearFilters => 'Filter zurücksetzen';
@override
String get exploreNothingMatches => 'Nichts Veröffentlichtes passt dazu.';
@override
String exploreBy(String author) {
return 'von $author';
}
@override
String exploreRelease(int version, String date) {
return 'v$version · $date';
}
@override
String exploreLatestRelease(int version, String date) {
return 'Neueste Veröffentlichung: v$version · $date';
}
@override
String get exploreAddToLibrary => 'Zur Bibliothek hinzufügen';
@override
String get exploreInLibrary => 'In deiner Bibliothek';
@override
String explorePercent(int percent) {
return '$percent %';
}
@override
String get exploreAdded => 'Zu deiner Bibliothek hinzugefügt.';
@override
String get exploreOpen => 'Öffnen';
@override
String get exploreNeedsNewerApp =>
'Dieses Lernset braucht eine neuere Version der App.';
@override
String get exploreFailed =>
'Deine Instanz war nicht erreichbar. Versuche es noch einmal.';
@override
String get exploreUnreachable => 'Entdecken ist gerade nicht erreichbar.';
@override
String get exploreRetry => 'Erneut versuchen';
@override
String get exploreMore => 'Mehr anzeigen';
@override
String get exploreNoDescription => 'Keine Beschreibung.';
@override
String get exploreReadLicence => 'Lizenz lesen';
@override
String get publishWithdraw => 'Aus Entdecken nehmen';
@override
String get publishWithdrawTitle => 'Aus Entdecken nehmen?';
@override
String get publishWithdrawMessage =>
'Niemand kann es dann noch hinzufügen, und Kopien bekommen keine Aktualisierungen. Seine Veröffentlichungen bleiben, wie sie sind; veröffentlichst du es wieder, ist es zurück.';
@override
String get publishWithdrawn =>
'Zurückgezogen · Kopien bekommen keine Aktualisierungen';
@override
String get publishWithdrawnDone => 'Aus Entdecken genommen.';
} }

View file

@ -5143,6 +5143,133 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get publishLocationCopied => 'Address copied.'; String get publishLocationCopied => 'Address copied.';
@override
String get publishedField => 'Release';
@override
String publishedVersion(int version) {
return 'v$version';
}
@override
String get exploreSearch => 'Search titles and authors';
@override
String get exploreLanguages => 'Languages';
@override
String get exploreAnyLanguage => 'Any';
@override
String exploreLanguagePair(String front, String back) {
return '$front → $back';
}
@override
String get exploreKinds => 'Content';
@override
String exploreKindsChosen(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count kinds of content',
one: '1 kind of content',
);
return '$_temp0';
}
@override
String get exploreKindCards => 'Flashcards';
@override
String get exploreKindNotes => 'Notes';
@override
String get exploreKindPaths => 'Learning paths';
@override
String get exploreKindPdfs => 'PDFs';
@override
String get exploreKindsIntro => 'Only studysets holding each of these.';
@override
String get exploreClearFilters => 'Clear filters';
@override
String get exploreNothingMatches => 'Nothing published here matches.';
@override
String exploreBy(String author) {
return 'by $author';
}
@override
String exploreRelease(int version, String date) {
return 'v$version · $date';
}
@override
String exploreLatestRelease(int version, String date) {
return 'Latest release: v$version · $date';
}
@override
String get exploreAddToLibrary => 'Add to library';
@override
String get exploreInLibrary => 'In your library';
@override
String explorePercent(int percent) {
return '$percent %';
}
@override
String get exploreAdded => 'Added to your library.';
@override
String get exploreOpen => 'Open';
@override
String get exploreNeedsNewerApp =>
'This studyset needs a newer version of the app.';
@override
String get exploreFailed => 'Couldn\'t reach your instance. Try again.';
@override
String get exploreUnreachable => 'Explore can\'t be reached right now.';
@override
String get exploreRetry => 'Retry';
@override
String get exploreMore => 'Show more';
@override
String get exploreNoDescription => 'No description.';
@override
String get exploreReadLicence => 'Read the licence';
@override
String get publishWithdraw => 'Take off Explore';
@override
String get publishWithdrawTitle => 'Take it off Explore?';
@override
String get publishWithdrawMessage =>
'Nobody can add it any more, and copies get no updates. Its releases stay as they are; publishing it again brings it back.';
@override
String get publishWithdrawn => 'Withdrawn · copies get no updates';
@override
String get publishWithdrawnDone => 'Taken off Explore.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).
@ -5228,4 +5355,7 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
String publishLicenceNotOpen(String titles) { String publishLicenceNotOpen(String titles) {
return 'These studysets\' licenses don\'t allow passing them on: $titles'; return 'These studysets\' licenses don\'t allow passing them on: $titles';
} }
@override
String get exploreReadLicence => 'Read the license';
} }

View file

@ -0,0 +1,27 @@
/// Where an item of a copy came from (ADR 0005, rule 4): the published
/// studyset (`publication`, its public id), the item there (its card's,
/// note's, step's, path's, PDF's or studyset's id in the release), and the
/// release whose content the item last took (`version`). A later release
/// is taken from that one on, so what the learner changed since stays.
typedef PublishedRef = ({String publication, String item, int version});
/// [ref] as content payloads store it: `{"publication", "item", "version"}`.
Map<String, Object?> publishedRefToJson(PublishedRef ref) => {
'publication': ref.publication,
'item': ref.item,
'version': ref.version,
};
/// The origin [json] names, as [publishedRefToJson] wrote it; null for null.
/// Throws a [FormatException] for anything else.
PublishedRef? publishedRefFromJson(Object? json) => switch (json) {
null => null,
{
'publication': final String publication,
'item': final String item,
'version': final int version,
}
when version > 0 =>
(publication: publication, item: item, version: version),
_ => throw const FormatException('A malformed published origin.'),
};

View file

@ -29,6 +29,9 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/serverpod_dictionary_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/serverpod_dictionary_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/kanji/data/stroke_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
@ -304,6 +307,11 @@ Future<PersonDependencies> _openPerson(
), ),
profile: ProfileRepository(ServerpodProfileGateway(client)), profile: ProfileRepository(ServerpodProfileGateway(client)),
publishing: PublishRepository(ServerpodPublishGateway(client)), publishing: PublishRepository(ServerpodPublishGateway(client)),
explore: ExploreRepository(
ServerpodExploreGateway(client),
ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets),
studysets,
),
onDispose: () async { onDispose: () async {
try { try {
// Deletions first: those still waiting are dropped, those under way // Deletions first: those still waiting are dropped, those under way

View file

@ -2,6 +2,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart'; import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
@ -83,6 +84,9 @@ class PersonDependencies({
/// Publishing the person's studysets, on the instance. /// Publishing the person's studysets, on the instance.
required final PublishRepository publishing, required final PublishRepository publishing,
/// What others published, and the person's copies of it.
required final ExploreRepository explore,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) { }) {
/// Closes what was opened for the person. /// Closes what was opened for the person.

View file

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart'; import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart'; import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.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/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -24,10 +25,12 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
// Schema 7 added the character card (type `character`) and a kanji's // Schema 7 added the character card (type `character`) and a kanji's
// readings, `on` and `kun`, each left out while empty. Schema 8 added the // readings, `on` and `kun`, each left out while empty. Schema 8 added the
// version of the dictionary entry the card took last (`version` in // version of the dictionary entry the card took last (`version` in
// `dictionary`), left out for one made before versions. // `dictionary`), left out for one made before versions. Schema 9 added where
// a copy's card came from (`published`, see published_ref.dart), left out for
// a card of its own.
/// The schema this app writes. /// The schema this app writes.
const cardSchemaVersion = 8; const cardSchemaVersion = 9;
/// Encodes a card's content into a revision's payload; [origin] names where /// Encodes a card's content into a revision's payload; [origin] names where
/// an imported card came from, in its first revision. /// an imported card came from, in its first revision.
@ -77,6 +80,8 @@ ByteData encodeCardPayload(CardContent content, {String? origin}) =>
'id': id, 'id': id,
'version': ?content.entryVersion, 'version': ?content.entryVersion,
}, },
if (content.published case final published?)
'published': publishedRefToJson(published),
if (content.senses.isNotEmpty) if (content.senses.isNotEmpty)
'senses': [ 'senses': [
for (final sense in content.senses) for (final sense in content.senses)
@ -157,6 +162,7 @@ CardContent decodeCardPayload(ByteData payload) {
{'version': final String version} => version, {'version': final String version} => version,
_ => null, _ => null,
}, },
published: publishedRefFromJson(json['published']),
); );
} }
throw const FormatException('Not a card payload.'); throw const FormatException('Not a card payload.');

View file

@ -1,5 +1,6 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart'; import 'package:halkyon_learn_flutter/src/core/content/pdf_page_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart'; import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
@ -78,6 +79,9 @@ enum CardField() {
/// The version of the dictionary entry the card took last. /// The version of the dictionary entry the card took last.
entryVersion, entryVersion,
/// Where a copy's card came from.
published,
} }
/// What a report says is wrong with a card. /// What a report says is wrong with a card.
@ -257,6 +261,10 @@ final class const CardContent({
/// corrected the entry since. Null for a card of its own, or made before /// corrected the entry since. Null for a card of its own, or made before
/// versions. /// versions.
final String? entryVersion, final String? entryVersion,
/// Where it came from, in a copy of a published studyset; null for a card
/// of the person's own.
final PublishedRef? published,
}) with Equatable implements MergeableContent<CardField, CardContent> { }) with Equatable implements MergeableContent<CardField, CardContent> {
/// A front/back card with nothing on either side. /// A front/back card with nothing on either side.
factory empty() => factory empty() =>
@ -284,6 +292,7 @@ final class const CardContent({
CardField.onReadings => onReadings, CardField.onReadings => onReadings,
CardField.kunReadings => kunReadings, CardField.kunReadings => kunReadings,
CardField.entryVersion => entryVersion, CardField.entryVersion => entryVersion,
CardField.published => published,
}; };
@override @override
@ -315,6 +324,9 @@ final class const CardContent({
entryVersion: field == CardField.entryVersion entryVersion: field == CardField.entryVersion
? value as String? ? value as String?
: entryVersion, : entryVersion,
published: field == CardField.published
? value as PublishedRef?
: published,
); );
/// This content with some fields replaced; [withField] clears the map, the /// This content with some fields replaced; [withField] clears the map, the
@ -335,6 +347,7 @@ final class const CardContent({
List<String>? onReadings, List<String>? onReadings,
List<String>? kunReadings, List<String>? kunReadings,
String? entryVersion, String? entryVersion,
PublishedRef? published,
}) => CardContent( }) => CardContent(
type: type ?? this.type, type: type ?? this.type,
front: front ?? this.front, front: front ?? this.front,
@ -353,6 +366,7 @@ final class const CardContent({
onReadings: onReadings ?? this.onReadings, onReadings: onReadings ?? this.onReadings,
kunReadings: kunReadings ?? this.kunReadings, kunReadings: kunReadings ?? this.kunReadings,
entryVersion: entryVersion ?? this.entryVersion, entryVersion: entryVersion ?? this.entryVersion,
published: published ?? this.published,
); );
/// The fields [type] uses; the others stay empty. The page it was made /// The fields [type] uses; the others stay empty. The page it was made
@ -405,6 +419,7 @@ final class const CardContent({
report: report, report: report,
entry: entry, entry: entry,
entryVersion: entryVersion, entryVersion: entryVersion,
published: published,
); );
for (final field in kept) { for (final field in kept) {
content = content.withField(field, this[field]); content = content.withField(field, this[field]);
@ -538,6 +553,7 @@ final class const CardContent({
onReadings, onReadings,
kunReadings, kunReadings,
entryVersion, entryVersion,
published,
]; ];
} }

View file

@ -742,6 +742,7 @@ class const _Conflicts({required final CardDraft draft})
CardField.onReadings => l10n.characterOnReadings, CardField.onReadings => l10n.characterOnReadings,
CardField.kunReadings => l10n.characterKunReadings, CardField.kunReadings => l10n.characterKunReadings,
CardField.entryVersion => l10n.cardDictionaryVersion, CardField.entryVersion => l10n.cardDictionaryVersion,
CardField.published => l10n.publishedField,
}; };
} }
@ -787,6 +788,9 @@ class const _Version({required final Object? version}) extends StatelessWidget {
AppLocalizations.of(context).pdfPageShort(page), AppLocalizations.of(context).pdfPageShort(page),
), ),
(source: final String source, id: final String id) => Text('$source $id'), (source: final String source, id: final String id) => Text('$source $id'),
(publication: _, item: _, version: final int version) => Text(
AppLocalizations.of(context).publishedVersion(version),
),
_ => Text('${version ?? ''}'), _ => Text('${version ?? ''}'),
}; };
} }

View file

@ -0,0 +1,236 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
/// Explore: published studysets found by fit, a page at a time, never by
/// popularity; and adding them to the person's library as copies.
class ExploreBloc(final ExploreRepository _explore)
extends Bloc<ExploreEvent, ExploreState> {
this : super(const ExploreState()) {
on<ExploreEventFiltered>(_onFiltered);
on<ExploreEventMoreRequested>(_onMore);
on<ExploreEventAddRequested>(_onAdd);
}
/// Counts the listings asked for, so an answer to one overtaken by a
/// newer one is dropped.
var _query = 0;
Future<void> _onFiltered(
ExploreEventFiltered event,
Emitter<ExploreState> emit,
) async {
final query = ++_query;
emit(
state.copyWith(
status: ExploreStatus.loading,
filter: event.filter,
studysets: const [],
hasMore: false,
),
);
try {
final (copies, (:page, :hasMore)) = await (
_explore.copies(),
_explore.list(event.filter),
).wait;
if (query != _query) {
return;
}
emit(
state.copyWith(
status: ExploreStatus.ready,
studysets: page,
hasMore: hasMore,
copies: copies,
),
);
} on Object catch (error) {
log('Reading Explore failed', name: 'Explore', error: error);
if (query == _query) {
emit(state.copyWith(status: ExploreStatus.unreachable));
}
}
}
Future<void> _onMore(
ExploreEventMoreRequested event,
Emitter<ExploreState> emit,
) async {
if (state.status != ExploreStatus.ready ||
!state.hasMore ||
state.loadingMore) {
return;
}
final query = _query;
emit(state.copyWith(loadingMore: true));
try {
final (:page, :hasMore) = await _explore.list(
state.filter,
offset: state.studysets.length,
);
if (query != _query) {
return;
}
emit(
state.copyWith(
studysets: [...state.studysets, ...page],
hasMore: hasMore,
loadingMore: false,
),
);
} on Object catch (error) {
log('Reading more of Explore failed', name: 'Explore', error: error);
if (query == _query) {
emit(
state.copyWith(
loadingMore: false,
outcome: const ExploreFailed(),
),
);
}
}
}
Future<void> _onAdd(
ExploreEventAddRequested event,
Emitter<ExploreState> emit,
) async {
final studyset = event.studyset;
if (state.adding.containsKey(studyset.publicId)) {
return;
}
emit(state.copyWith(adding: {...state.adding, studyset.publicId: null}));
try {
final id = await _explore.addToLibrary(
studyset,
onProgress: (done) => emit(
state.copyWith(adding: {...state.adding, studyset.publicId: done}),
),
);
emit(
state.copyWith(
adding: {...state.adding}..remove(studyset.publicId),
copies: {...state.copies, studyset.publicId: id},
outcome: ExploreAdded(id),
),
);
} on ReleaseUnreadable {
emit(
state.copyWith(
adding: {...state.adding}..remove(studyset.publicId),
outcome: const ExploreNeedsNewerApp(),
),
);
} on Object catch (error) {
log('Adding to the library failed', name: 'Explore', error: error);
emit(
state.copyWith(
adding: {...state.adding}..remove(studyset.publicId),
outcome: const ExploreFailed(),
),
);
}
}
}
/// Where Explore's listing stands.
enum ExploreStatus() {
loading,
ready,
unreachable,
}
/// What a request came to, said once.
sealed class const ExploreOutcome() with Equatable {
@override
List<Object?> get props => const [];
}
/// A copy was made: the top studyset [studysetId].
final class const ExploreAdded(final String studysetId) extends ExploreOutcome {
@override
List<Object?> get props => [studysetId];
}
/// The release needs a newer version of the app; nothing was added.
final class const ExploreNeedsNewerApp() extends ExploreOutcome;
/// The instance couldn't be reached; nothing changed.
final class const ExploreFailed() extends ExploreOutcome;
/// Explore as its screen shows it: the [filter] and what answers it, a
/// page at a time, which of them the person has a copy of ([copies], by
/// public id, the copy's top studyset), which are being added and how far
/// ([adding], null before the first step), and what the last request came
/// to ([outcome], cleared by the next state).
final class const ExploreState({
final ExploreStatus status = ExploreStatus.loading,
final ExploreFilter filter = const ExploreFilter(),
final List<ExploreStudyset> studysets = const [],
final bool hasMore = false,
final bool loadingMore = false,
final Map<String, String> copies = const {},
final Map<String, double?> adding = const {},
final ExploreOutcome? outcome,
}) with Equatable {
/// This state with what is given changed, and no outcome unless given.
ExploreState copyWith({
ExploreStatus? status,
ExploreFilter? filter,
List<ExploreStudyset>? studysets,
bool? hasMore,
bool? loadingMore,
Map<String, String>? copies,
Map<String, double?>? adding,
ExploreOutcome? outcome,
}) => ExploreState(
status: status ?? this.status,
filter: filter ?? this.filter,
studysets: studysets ?? this.studysets,
hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore,
copies: copies ?? this.copies,
adding: adding ?? this.adding,
outcome: outcome,
);
@override
List<Object?> get props => [
status,
filter,
studysets,
hasMore,
loadingMore,
copies,
adding,
outcome,
];
}
/// What happens in Explore.
sealed class const ExploreEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// Explore shows, or is asked anew for [filter]: list what answers it.
final class const ExploreEventFiltered(final ExploreFilter filter)
extends ExploreEvent {
@override
List<Object?> get props => [filter];
}
/// The person asks for the next page.
final class const ExploreEventMoreRequested() extends ExploreEvent;
/// The person adds [studyset] to their library.
final class const ExploreEventAddRequested(final ExploreStudyset studyset)
extends ExploreEvent {
@override
List<Object?> get props => [studyset];
}

View file

@ -0,0 +1,23 @@
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
/// The boundary to Explore on the instance. Tests replace it with a fake.
abstract interface class ExploreGateway() {
/// How many studysets a page holds at most: a shorter one is the last.
int get pageSize;
/// A page of published studysets answering [filter], the newest release
/// first, [offset] of them skipped.
Future<List<ExploreStudyset>> list(ExploreFilter filter, {int offset = 0});
/// The published studysets [publicIds] as listed now, those withdrawn
/// left out.
Future<List<ExploreStudyset>> entries(List<String> publicIds);
/// The latest release of the published studyset [publicId].
Future<PublishedRelease> release(String publicId);
/// The file [sha256] of release [version] of [publicId].
Future<Uint8List> file(String publicId, int version, String sha256);
}

View file

@ -0,0 +1,53 @@
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
/// Explore (ADR 0005, rule 4): the studysets published on the instance,
/// and copies of them in the person's library. Listing needs the instance;
/// a copy, once made, is the person's own, offline like the rest.
class ExploreRepository(
final ExploreGateway _gateway,
final ReleaseCopier _copier,
final StudysetRepository _studysets,
) {
/// A page of published studysets answering [filter], the newest release
/// first, [offset] of them skipped; whether more may follow.
Future<({List<ExploreStudyset> page, bool hasMore})> list(
ExploreFilter filter, {
int offset = 0,
}) async {
final page = await _gateway.list(filter, offset: offset);
return (page: page, hasMore: page.length >= _gateway.pageSize);
}
/// The published studysets the person has a copy of, each with the
/// copy's top studyset.
Future<Map<String, String>> copies() async => {
for (final studyset in await _studysets.all())
if (studyset.content case StudysetContent(
published: (:final publication, item: _, version: _),
publishedAt: _?,
))
publication: studyset.id,
};
/// Copies the latest release of [studyset] into the person's library and
/// returns the copy's top studyset; tells [onProgress] how much is done,
/// from 0 to 1. Throws [ReleaseUnreadable] for a release this app can't
/// read all of, having written nothing.
Future<String> addToLibrary(
ExploreStudyset studyset, {
void Function(double done)? onProgress,
}) async {
final release = await _gateway.release(studyset.publicId);
return await _copier.copy(
release,
location: studyset.location,
fileOf: (sha256) =>
_gateway.file(studyset.publicId, release.version, sha256),
onProgress: onProgress,
);
}
}

View file

@ -0,0 +1,394 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
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/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// A release this app can't make a copy of: one of its items is in a
/// schema newer than this app knows, or not what a release holds.
final class ReleaseUnreadable() implements Exception {
@override
String toString() => 'ReleaseUnreadable';
}
/// Makes a copy of a published studyset's release in the person's library
/// (ADR 0005, rule 4): their own studysets, cards, notes, learning paths and
/// PDFs, with the files the content names among their own files and the
/// tags by name among their own tags, each item knowing where it came from
/// (`published`, see published_ref.dart) so that a later release can be
/// taken into it. Progress, reviews and anything else of the author's never
/// travel: a release holds none.
///
/// The copy is written through the person's repositories, as anything they
/// make, and syncs with the rest; nothing of it is written on the instance
/// in their name.
class ReleaseCopier(
final StudysetRepository _studysets,
final CardRepository _cards,
final NoteRepository _notes,
final LearningPathRepository _paths,
final PdfSourceRepository _pdfs,
final TagRepository _tags,
final AssetRepository _assets,
) {
/// Copies [release] of the published studyset at [location] into the
/// library, the files its content names read through [fileOf]; returns
/// the copy's top studyset. Tells [onProgress] how much is done, from 0
/// to 1.
///
/// Throws [ReleaseUnreadable] before writing anything when this app can't
/// read all of it.
Future<String> copy(
PublishedRelease release, {
required String location,
required Future<Uint8List> Function(String sha256) fileOf,
void Function(double done)? onProgress,
}) async {
// Read all of it first: a copy is whole or not made.
_read(release, const {});
final files = switch (release.items['files.json']) {
final Map<String, Object?> index => index,
null => const <String, Object?>{},
};
// Every file, and every item but the files' index.
final total =
files.length +
release.items.length -
(release.items.containsKey('files.json') ? 1 : 0);
var done = 0;
void step() => onProgress?.call(++done / total);
// The files first, as the person's own: the content then names them.
final assetIds = <String, String>{};
for (final MapEntry(key: id, value: file) in files.entries) {
if (file case {
'sha256': final String sha256,
'mimeType': final String mimeType,
}) {
assetIds[id] = await _assets.add(
await fileOf(sha256),
fileName: switch (file['payload']) {
{'fileName': final String name} when name.isNotEmpty => name,
_ => sha256,
},
mimeType: mimeType,
);
}
step();
}
final read = _read(release, assetIds);
PublishedRef origin(String id) =>
(publication: release.publicId, item: id, version: release.version);
// The studysets, each after the one it is in, in their order.
final studysetIds = <String, String>{};
final rootItem = read.studysets['']!;
studysetIds[''] = await _studysets.create(
_withOrigin(rootItem.content, origin(rootItem.id), location: location),
);
step();
for (final directory in read.directories) {
if (directory.isEmpty) {
continue;
}
final item = read.studysets[directory]!;
studysetIds[directory] = await _studysets.create(
_withOrigin(item.content, origin(item.id)),
parentId: studysetIds[_parentOf(directory)],
);
step();
}
final tagIds = <String, String>{};
Future<Set<String>> tagsOf(List<String> names) async => {
for (final name in names) tagIds[name] ??= await _tags.create(name),
};
for (final directory in read.directories) {
final studysetId = studysetIds[directory]!;
final cards = _ordered(read.cards[directory]);
final cardIds = await _cards.createAll(studysetId, [
for (final card in cards)
card.content.withField(CardField.published, origin(card.id)),
]);
await _tags.addTags({
for (final (i, card) in cards.indexed)
if (card.tags.isNotEmpty) cardIds[i]: await tagsOf(card.tags),
});
done += cards.length;
onProgress?.call(done / total);
for (final note in _ordered(read.notes[directory])) {
final (:noteId, revisionId: _) = await _notes.create(
studysetId,
note.content.withField(NoteField.published, origin(note.id)),
);
if (note.tags.isNotEmpty) {
await _tags.setTagsOfNote(noteId, await tagsOf(note.tags));
}
step();
}
for (final path in _ordered(read.paths[directory])) {
final pathId = await _paths.createPath(
studysetId,
_pathWithOrigin(path.content, origin(path.id)),
);
if (path.tags.isNotEmpty) {
await _tags.setTagsOfPath(pathId, await tagsOf(path.tags));
}
step();
for (final stepItem in _ordered(read.steps[path.id])) {
await _paths.createStep(
pathId,
stepItem.kind!,
stepItem.content.withField(
StepField.published,
origin(stepItem.id),
),
);
step();
}
}
for (final pdf in _ordered(read.pdfs[directory])) {
await _pdfs.add(
studysetId,
_pdfWithOrigin(pdf.content, origin(pdf.id)),
);
step();
}
}
return studysetIds['']!;
}
/// [release]'s items decoded, each file of the release named by the
/// asset row [assetIds] gives its old one; throws [ReleaseUnreadable] for
/// an item this app can't read.
_Read _read(PublishedRelease release, Map<String, String> assetIds) {
final read = _Read();
try {
for (final MapEntry(key: path, value: json) in release.items.entries) {
if (path == 'files.json') {
continue;
}
final segments = path.split('/');
final kindAt = segments.indexWhere(_kinds.contains);
final directory = segments
.take(kindAt < 0 ? segments.length - 1 : kindAt)
.join('/');
final id = json['id']! as String;
final position = switch (json['position']) {
final String position => position,
_ => 'a0',
};
final tags = [
for (final tag in json['tags'] as List<Object?>? ?? const [])
tag! as String,
];
// The person's own files in place of the author's.
var text = jsonEncode(json['payload']);
for (final MapEntry(key: old, value: own) in assetIds.entries) {
text = text.replaceAll(old, own);
}
final payload = ByteData.sublistView(utf8.encode(text));
switch (kindAt < 0 ? 'studyset.json' : segments[kindAt]) {
case 'studyset.json' when segments.last == 'studyset.json':
read.studysets[directory] = _Item(
id: id,
position: position,
content: decodeStudysetPayload(payload),
);
case 'cards':
(read.cards[directory] ??= []).add(
_Item(
id: id,
position: position,
tags: tags,
content: decodeCardPayload(payload),
),
);
case 'notes':
(read.notes[directory] ??= []).add(
_Item(
id: id,
position: position,
tags: tags,
content: decodeNotePayload(payload),
),
);
case 'paths' when segments.last == 'path.json':
(read.paths[directory] ??= []).add(
_Item(
id: id,
position: position,
tags: tags,
content: decodeLearningPathPayload(payload),
),
);
case 'paths':
final kind = StepKind.of(json['kind']! as String);
if (kind == null) {
throw ReleaseUnreadable();
}
// `paths/<path id>/steps/<id>.json`
(read.steps[segments[kindAt + 1]] ??= []).add(
_Item(
id: id,
position: position,
kind: kind,
content: decodeStepPayload(payload, kind),
),
);
case 'pdfs':
(read.pdfs[directory] ??= []).add(
_Item(
id: id,
position: position,
content: decodePdfSourcePayload(payload),
),
);
default:
throw ReleaseUnreadable();
}
}
} on ReleaseUnreadable {
rethrow;
} on Object {
// A newer schema, or not a release's item.
throw ReleaseUnreadable();
}
if (!read.studysets.containsKey('')) {
throw ReleaseUnreadable();
}
return read;
}
static const _kinds = {'cards', 'notes', 'paths', 'pdfs'};
/// The directory of the studyset [directory] is in.
static String _parentOf(String directory) => switch (directory.lastIndexOf(
'/',
)) {
-1 => '',
final at => directory.substring(0, at),
};
/// [items] in their order: by position, ties by id.
static List<_Item<T>> _ordered<T>(List<_Item<T>>? items) => [...?items]
..sort(
(a, b) => switch (a.position.compareTo(b.position)) {
0 => a.id.compareTo(b.id),
final order => order,
},
);
static StudysetContent _withOrigin(
StudysetContent content,
PublishedRef origin, {
String? location,
}) => StudysetContent(
title: content.title,
description: content.description,
secondaryTitle: content.secondaryTitle,
colorName: content.colorName,
license: content.license,
licenseHolder: content.licenseHolder,
licenseYear: content.licenseYear,
frontLanguage: content.frontLanguage,
backLanguage: content.backLanguage,
studyOptions: content.studyOptions,
bibliography: content.bibliography,
published: origin,
publishedAt: location,
);
static LearningPathContent _pathWithOrigin(
LearningPathContent content,
PublishedRef origin,
) => LearningPathContent(
title: content.title,
secondaryTitle: content.secondaryTitle,
description: content.description,
published: origin,
);
static PdfSourceContent _pdfWithOrigin(
PdfSourceContent content,
PublishedRef origin,
) => PdfSourceContent(
title: content.title,
fileName: content.fileName,
assetId: content.assetId,
pageCount: content.pageCount,
published: origin,
);
}
/// A release's items decoded, by the directory of the studyset they are in
/// (`''` for the top one, `<id>/<id>` further in); steps by their path.
final class _Read() {
final studysets = <String, _Item<StudysetContent>>{};
final cards = <String, List<_Item<CardContent>>>{};
final notes = <String, List<_Item<NoteContent>>>{};
final paths = <String, List<_Item<LearningPathContent>>>{};
final steps = <String, List<_Item<StepContent>>>{};
final pdfs = <String, List<_Item<PdfSourceContent>>>{};
/// The studysets' directories, each after the one it is in, siblings in
/// their order.
List<String> get directories {
final ordered = <String>[];
void visit(String directory) {
ordered.add(directory);
final children =
[
for (final MapEntry(key: child, value: item) in studysets.entries)
if (child.isNotEmpty &&
ReleaseCopier._parentOf(child) == directory)
(child, item),
]..sort(
(a, b) => switch (a.$2.position.compareTo(b.$2.position)) {
0 => a.$2.id.compareTo(b.$2.id),
final order => order,
},
);
for (final (child, _) in children) {
visit(child);
}
}
visit('');
return ordered;
}
}
/// An item of a release: its id there, its position, its tags by name, a
/// step's kind, and its content.
final class _Item<T>({
required final String id,
required final String position,
required final T content,
final List<String> tags = const [],
final StepKind? kind,
});

View file

@ -0,0 +1,112 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
/// [ExploreGateway] asking the instance through the Serverpod client.
class ServerpodExploreGateway(final protocol.Client _client)
implements ExploreGateway {
/// As the instance's Explore endpoint pages (`explorePageSize`).
@override
int get pageSize => 30;
@override
Future<List<ExploreStudyset>> list(
ExploreFilter filter, {
int offset = 0,
}) async => [
for (final entry in await _client.explore.list(
protocol.ExploreQuery(
text: filter.text,
frontLanguage: filter.frontLanguage,
backLanguage: filter.backLanguage,
withCards: filter.kinds.contains(ContentKind.cards),
withNotes: filter.kinds.contains(ContentKind.notes),
withPaths: filter.kinds.contains(ContentKind.paths),
withPdfs: filter.kinds.contains(ContentKind.pdfs),
offset: offset,
),
))
_studyset(entry),
];
@override
Future<List<ExploreStudyset>> entries(List<String> publicIds) async => [
for (final entry in await _client.explore.entries([
for (final id in publicIds) protocol.UuidValue.withValidation(id),
]))
_studyset(entry),
];
@override
Future<PublishedRelease> release(String publicId) async {
final release = await _client.explore.release(
protocol.UuidValue.withValidation(publicId),
);
return PublishedRelease(
publicId: publicId,
version: release.version,
items: {
for (final item in release.items)
if (jsonDecode(item.json) case final Map<String, Object?> json)
item.path: json,
},
files: release.files,
);
}
@override
Future<Uint8List> file(String publicId, int version, String sha256) async =>
Uint8List.sublistView(
await _client.explore.file(
protocol.UuidValue.withValidation(publicId),
version,
sha256,
),
);
ExploreStudyset _studyset(protocol.ExploreEntry entry) => ExploreStudyset(
publicId: entry.publicId.uuid,
location: entry.location,
handle: entry.handle,
displayName: entry.displayName,
avatarUrl: entry.avatarUrl,
version: entry.version,
releasedAt: entry.releasedAt,
notes: entry.notes,
title: entry.title,
secondaryTitle: entry.secondaryTitle,
description: _description(entry.description),
frontLanguage: entry.frontLanguage,
backLanguage: entry.backLanguage,
license: entry.license,
licenseHolder: entry.licenseHolder,
licenseYear: entry.licenseYear,
studysets: entry.studysetCount,
cards: entry.cardCount,
noteCount: entry.noteCount,
paths: entry.pathCount,
pdfs: entry.pdfCount,
);
/// A description as the instance keeps it, its rich text's JSON; empty
/// for none, or one this app can't read.
RichContent _description(String? json) {
if (json == null) {
return RichContent.empty();
}
try {
return switch (jsonDecode(json)) {
final Map<String, Object?> description => RichContent.fromJson(
description,
),
_ => RichContent.empty(),
};
} on FormatException {
return RichContent.empty();
}
}
}

View file

@ -0,0 +1,113 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// A published studyset as Explore shows it (ADR 0005, rule 4): where it
/// is, who made it, and its latest release with what it holds.
final class const ExploreStudyset({
/// Its identity for good.
required final String publicId,
/// Where it is (`halkyon.eu/@raisondetredev/japanese`).
required final String location,
/// Its author's handle, and display name (empty for none).
required final String handle,
final String displayName = '',
/// Where its author's picture is; null for none.
final String? avatarUrl,
/// Its latest release: its version, when, and what changed (empty for
/// nothing said).
required final int version,
required final DateTime releasedAt,
final String notes = '',
/// What it says of itself.
required final String title,
final String? secondaryTitle,
required final RichContent description,
final String? frontLanguage,
final String? backLanguage,
/// Its licence and credits.
required final String license,
final String? licenseHolder,
final int? licenseYear,
/// What it holds.
final int studysets = 1,
final int cards = 0,
final int noteCount = 0,
final int paths = 0,
final int pdfs = 0,
}) with Equatable {
/// Its author as they are shown: their display name, else their handle.
String get author => displayName.isEmpty ? '@$handle' : displayName;
@override
List<Object?> get props => [
publicId,
location,
handle,
displayName,
avatarUrl,
version,
releasedAt,
notes,
title,
secondaryTitle,
description,
frontLanguage,
backLanguage,
license,
licenseHolder,
licenseYear,
studysets,
cards,
noteCount,
paths,
pdfs,
];
}
/// A kind of content a studyset may hold, for Explore to look for.
enum ContentKind() {
cards,
notes,
paths,
pdfs,
}
/// What Explore is asked for: words to find in titles and authors, the
/// languages (a primary one, `ja`, takes its regional ones too), and the
/// kinds of content a studyset must hold.
final class const ExploreFilter({
final String text = '',
final String? frontLanguage,
final String? backLanguage,
final Set<ContentKind> kinds = const {},
}) with Equatable {
/// Whether it asks for anything but the newest.
bool get isEmpty =>
text.trim().isEmpty &&
frontLanguage == null &&
backLanguage == null &&
kinds.isEmpty;
@override
List<Object?> get props => [text, frontLanguage, backLanguage, kinds];
}
/// A release's content, to make a copy of or take into one: its files of
/// JSON by path, decoded, as the release holds them (`studyset.json`,
/// `cards/<id>.json`, …), and the SHA-256 of each file their content names.
final class const PublishedRelease({
required final String publicId,
required final int version,
required final Map<String, Map<String, Object?>> items,
final List<String> files = const [],
}) with Equatable {
@override
List<Object?> get props => [publicId, version, items, files];
}

View file

@ -1,23 +1,127 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'dart:async';
import 'package:material_ui/material_ui.dart';
/// The explore tab: where studysets others share will be found. Empty until import 'package:flutter_bloc/flutter_bloc.dart';
/// sharing exists. import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.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/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/bloc/explore_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_summary_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart';
/// The Explore tab (ADR 0005, rule 4): the studysets published on the
/// instance, found by fit (search, languages, kinds of content), the newest
/// release first, never by popularity; a page at a time, the next only when
/// asked for. Each opens a summary, and is added to the library as a copy.
class const ExploreScreen({super.key}) extends StatelessWidget { class const ExploreScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) => BlocProvider(
create: (context) =>
ExploreBloc(PersonScope.of(context).explore)
..add(const ExploreEventFiltered(ExploreFilter())),
child: const _Explore(),
);
}
class const _Explore() extends StatefulWidget {
@override
State<_Explore> createState() => _ExploreState();
}
class _ExploreState() extends State<_Explore> {
/// Waits for typing to pause before asking the instance.
Timer? _typing;
@override
void dispose() {
_typing?.cancel();
super.dispose();
}
void _filter(ExploreFilter Function(ExploreFilter filter) change) {
final bloc = context.read<ExploreBloc>();
bloc.add(ExploreEventFiltered(change(bloc.state.filter)));
}
void _search(String text) {
_typing?.cancel();
_typing = Timer(
const Duration(milliseconds: 400),
() => _filter(
(filter) => ExploreFilter(
text: text,
frontLanguage: filter.frontLanguage,
backLanguage: filter.backLanguage,
kinds: filter.kinds,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.exploreTitle)), appBar: AppBar(title: Text(l10n.exploreTitle)),
body: Center( body: BlocConsumer<ExploreBloc, ExploreState>(
child: Padding( listenWhen: (before, now) => now.outcome != null,
padding: const EdgeInsets.all(24), listener: (context, state) {
child: Text( final messenger = ScaffoldMessenger.of(context)
l10n.exploreEmpty, ..hideCurrentSnackBar();
textAlign: TextAlign.center, switch (state.outcome) {
style: theme.textTheme.bodyLarge?.copyWith( case ExploreAdded(:final studysetId):
color: theme.colorScheme.onSurfaceVariant, messenger.showSnackBar(
SnackBar(
content: Text(l10n.exploreAdded),
action: SnackBarAction(
label: l10n.exploreOpen,
onPressed: () =>
context.go(RoutePaths.studyset(studysetId)),
),
),
);
case ExploreNeedsNewerApp():
messenger.showSnackBar(
SnackBar(content: Text(l10n.exploreNeedsNewerApp)),
);
case ExploreFailed():
messenger.showSnackBar(
SnackBar(content: Text(l10n.exploreFailed)),
);
case null:
break;
}
},
builder: (context, state) => Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: SearchBar(
hintText: l10n.exploreSearch,
leading: const Icon(LucideIcons.search),
elevation: const WidgetStatePropertyAll(0),
onChanged: _search,
),
),
_Filters(filter: state.filter, onChanged: _filter),
Expanded(child: _Results(state: state)),
],
), ),
), ),
), ),
@ -25,3 +129,440 @@ class const ExploreScreen({super.key}) extends StatelessWidget {
); );
} }
} }
/// The languages and kinds of content asked for, each a chip opening its
/// sheet.
class const _Filters({
required final ExploreFilter filter,
required final void Function(
ExploreFilter Function(ExploreFilter filter) change,
)
onChanged,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
String nameOf(String? tag) =>
tag == null ? l10n.exploreAnyLanguage : contentLanguageName(l10n, tag);
final languagesChosen =
filter.frontLanguage != null || filter.backLanguage != null;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilterChip(
avatar: const Icon(LucideIcons.languages, size: 18),
label: Text(
languagesChosen
? l10n.exploreLanguagePair(
nameOf(filter.frontLanguage),
nameOf(filter.backLanguage),
)
: l10n.exploreLanguages,
),
selected: languagesChosen,
showCheckmark: false,
onSelected: (_) => showAdaptiveSheet<void>(
context,
builder: (sheetContext) => _LanguagesSheet(
filter: filter,
onChanged: (front, back) => onChanged(
(filter) => ExploreFilter(
text: filter.text,
frontLanguage: front,
backLanguage: back,
kinds: filter.kinds,
),
),
),
),
),
FilterChip(
avatar: const Icon(LucideIcons.layers, size: 18),
label: Text(
filter.kinds.isEmpty
? l10n.exploreKinds
: l10n.exploreKindsChosen(filter.kinds.length),
),
selected: filter.kinds.isNotEmpty,
showCheckmark: false,
onSelected: (_) => showAdaptiveSheet<void>(
context,
builder: (sheetContext) => _KindsSheet(
kinds: filter.kinds,
onChanged: (kinds) => onChanged(
(filter) => ExploreFilter(
text: filter.text,
frontLanguage: filter.frontLanguage,
backLanguage: filter.backLanguage,
kinds: kinds,
),
),
),
),
),
],
),
);
}
}
/// The languages of the fronts and backs to look for, each to choose or
/// leave open; every change applies as it is made.
class const _LanguagesSheet({
required final ExploreFilter filter,
required final void Function(String? front, String? back) onChanged,
}) extends StatefulWidget {
@override
State<_LanguagesSheet> createState() => _LanguagesSheetState();
}
class _LanguagesSheetState() extends State<_LanguagesSheet> {
late String? _front = widget.filter.frontLanguage;
late String? _back = widget.filter.backLanguage;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final languages = [
for (final tag in contentLanguages)
(tag: tag, name: contentLanguageName(l10n, tag)),
]..sort((a, b) => a.name.compareTo(b.name));
Widget dropdown(
String label,
String? value,
ValueChanged<String?> onChanged,
) => DropdownButtonFormField<String?>(
initialValue: value,
isExpanded: true,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
items: [
DropdownMenuItem(child: Text(l10n.exploreAnyLanguage)),
for (final language in languages)
DropdownMenuItem(value: language.tag, child: Text(language.name)),
],
onChanged: onChanged,
);
return SheetScaffold(
title: l10n.exploreLanguages,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
dropdown(l10n.studysetSheetFrontLanguage, _front, (language) {
setState(() => _front = language);
widget.onChanged(_front, _back);
}),
const SizedBox(height: 12),
dropdown(l10n.studysetSheetBackLanguage, _back, (language) {
setState(() => _back = language);
widget.onChanged(_front, _back);
}),
],
),
);
}
}
/// The kinds of content a studyset must hold, each to tick; every change
/// applies as it is made.
class const _KindsSheet({
required final Set<ContentKind> kinds,
required final ValueChanged<Set<ContentKind>> onChanged,
}) extends StatefulWidget {
@override
State<_KindsSheet> createState() => _KindsSheetState();
}
class _KindsSheetState() extends State<_KindsSheet> {
late var _kinds = widget.kinds;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.exploreKinds,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.exploreKindsIntro),
const SizedBox(height: 8),
for (final (kind, label) in [
(ContentKind.cards, l10n.exploreKindCards),
(ContentKind.notes, l10n.exploreKindNotes),
(ContentKind.paths, l10n.exploreKindPaths),
(ContentKind.pdfs, l10n.exploreKindPdfs),
])
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(label),
value: _kinds.contains(kind),
onChanged: (on) {
setState(
() => _kinds = on ?? false
? {..._kinds, kind}
: ({..._kinds}..remove(kind)),
);
widget.onChanged(_kinds);
},
),
],
),
);
}
}
/// What answers the filter: a spinner, a way to try again, a word that
/// nothing does, or the studysets with Show more below while there may be
/// more.
class const _Results({required final ExploreState state})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
void send(ExploreEvent event) => context.read<ExploreBloc>().add(event);
Widget message(String text, {Widget? action}) => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (action != null) ...[const SizedBox(height: 12), action],
],
),
),
);
return switch (state) {
ExploreState(status: ExploreStatus.loading) => const Center(
child: AppLoadingIndicator(),
),
ExploreState(status: ExploreStatus.unreachable) => message(
l10n.exploreUnreachable,
action: FilledButton.tonal(
onPressed: () => send(ExploreEventFiltered(state.filter)),
child: Text(l10n.exploreRetry),
),
),
ExploreState(studysets: [], filter: ExploreFilter(isEmpty: true)) =>
message(l10n.exploreEmpty),
ExploreState(studysets: []) => message(
l10n.exploreNothingMatches,
action: TextButton(
onPressed: () => send(const ExploreEventFiltered(ExploreFilter())),
child: Text(l10n.exploreClearFilters),
),
),
_ => RefreshIndicator(
onRefresh: () async => send(ExploreEventFiltered(state.filter)),
child: ListView(
padding: const EdgeInsets.all(12),
physics: const AlwaysScrollableScrollPhysics(),
children: [
for (final studyset in state.studysets)
_ExploreCard(studyset: studyset, state: state),
if (state.hasMore)
Center(
child: state.loadingMore
? const Padding(
padding: EdgeInsets.all(8),
child: AppLoadingIndicator(),
)
: TextButton(
onPressed: () =>
send(const ExploreEventMoreRequested()),
child: Text(l10n.exploreMore),
),
),
],
),
),
};
}
}
/// A published studyset in the list: its title, author, the start of its
/// description, its latest release and what it holds, its licence, and
/// Add to library; tapping it opens its summary.
class const _ExploreCard({
required final ExploreStudyset studyset,
required final ExploreState state,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final muted = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final description = studyset.description.plainText.trim();
return Card(
margin: const EdgeInsets.only(bottom: 8),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => showExploreSummary(context, studyset),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(studyset.title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(l10n.exploreBy(authorLine(studyset)), style: muted),
if (description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
description,
style: muted,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 4),
Text(
l10n.exploreRelease(
studyset.version,
releaseDate(context, studyset.releasedAt),
),
style: muted,
),
ExploreCounts(studyset: studyset),
const SizedBox(height: 12),
Row(
children: [
Flexible(child: LicenseChip(license: studyset.license)),
const Spacer(),
ExploreAddButton(studyset: studyset, state: state),
],
),
],
),
),
),
);
}
}
/// [studyset]'s author as Explore names them: their display name with
/// their handle, or the handle alone.
String authorLine(ExploreStudyset studyset) => studyset.displayName.isEmpty
? '@${studyset.handle}'
: '${studyset.displayName} · @${studyset.handle}';
/// [at], a release's date, as the person's language writes days.
String releaseDate(BuildContext context, DateTime at) => DateFormat.yMMMd(
AppLocalizations.of(context).localeName,
).format(at.toLocal());
/// What [studyset] holds, in one line.
class const ExploreCounts({required final ExploreStudyset studyset, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ContentCountsLine(
counts: [
(count: studyset.paths, label: l10n.studysetCountPaths),
(count: studyset.cards, label: l10n.studysetCountFlashcards),
(count: studyset.noteCount, label: l10n.studysetCountNotes),
(count: studyset.pdfs, label: l10n.studysetCountFiles),
],
);
}
}
/// A licence as a chip: opening its text where that is known.
class const LicenseChip({required final String license, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final url = licenseUrl(license);
if (url == null) {
return Chip(label: Text(license), visualDensity: VisualDensity.compact);
}
return ActionChip(
avatar: Icon(
LucideIcons.externalLink,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
label: Text(license),
visualDensity: VisualDensity.compact,
tooltip: AppLocalizations.of(context).exploreReadLicence,
onPressed: () => unawaited(launchUrl(url)),
);
}
}
/// Add to library for [studyset]: its progress while it is being added, In
/// your library (opening the copy) once there is a copy. [wide] in the
/// summary sheet, which either closes.
class const ExploreAddButton({
required final ExploreStudyset studyset,
required final ExploreState state,
final bool wide = false,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final style = wide
? FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))
: null;
if (state.copies[studyset.publicId] case final copy?) {
return TextButton.icon(
style: wide
? TextButton.styleFrom(minimumSize: const Size.fromHeight(48))
: null,
onPressed: () {
final router = GoRouter.of(context);
if (wide) {
Navigator.of(context).pop();
}
router.go(RoutePaths.studyset(copy));
},
icon: const Icon(LucideIcons.check),
label: Text(l10n.exploreInLibrary),
);
}
if (state.adding.containsKey(studyset.publicId)) {
final progress = state.adding[studyset.publicId];
return FilledButton.tonal(
style: style,
onPressed: null,
child: progress == null
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.explorePercent((progress * 100).round())),
);
}
return FilledButton.tonalIcon(
style: style,
onPressed: () {
final bloc = context.read<ExploreBloc>();
// From the summary, it goes on in the list: its progress shows on
// the studyset's card, and what came of it below the list.
if (wide) {
Navigator.of(context).pop();
}
bloc.add(ExploreEventAddRequested(studyset));
},
icon: const Icon(LucideIcons.plus),
label: Text(l10n.exploreAddToLibrary),
);
}
}

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