feat(explore): cards as the library's, in its colour, with a description of their own

Explore lists its studysets in a grid as the library does: each card in
the studyset's colour along the top, its title under its secondary
title, the start of its description and what it holds, then, quieter,
its author's handle, the release and the licence, and a compact Add.
A tap opens the summary, which shows the colour too.

The Publish sheet has a Description on Explore, starting from the
latest release's, else the studyset's own, and sent with the release:
a studyset may introduce itself to strangers while its own description
stays empty in the library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 17:47:29 +02:00
commit 21b71a4413
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 397 additions and 102 deletions

View file

@ -167,7 +167,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own, and says why a handle couldn't be theirs (`checkHandle`, null when it can: whose a handle is, `byHandle` tells anyone already); `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it in a card of its own after the account's and the inbox's (each part a card, as Settings groups its own), as a form, for people with an identity, that saves as it is written (decided 2026-10-12): a field when it is left or Enter is pressed in it, a link when removed, never twice the same; the handle is chosen in a dialog that asks the instance while it is typed whether it is free, and choosing the first makes the profile with what was written (`PublicProfileBloc` takes saves one at a time, the latest of those waiting next, and a handle chosen holds for every save after it).
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a studyset's `exam`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. The library's cards say who made a studyset ("By you", or a copy's author by the handle in its `publishedAt`) and whether it is out on Explore (a lock or a globe, opening the Publish sheet through `showShareSheet`): `published()` lists the person's studysets published and not withdrawn, and `PublishRepository` remembers them in `published.json` in the person's folder (`PublishedStore`), so the library shows them offline too; publishing, withdrawing and looking one up update it.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted (`countLearning` in their settings, settings schema 8, off unless switched on in Settings › Privacy) and studied a copy of it (a studyset whose payload names it) on 3 or more days of the last 30, fresh starts left out; payloads are decoded in Dart, so one that isn't JSON counts for nothing rather than failing the count. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is a grid of cards as the library's (decided 2026-10-14): the studyset's colour along the top (`colorName`, which a release keeps since then), its title under its secondary title, the start of its description, what it holds, then quieter its author's handle, the release and the licence, and Add (its progress, then In library, opening the copy); a tap opens a summary sheet with all of it. What Explore shows as the description is what its author wrote in the Publish sheet (Description on Explore, starting from the latest release's, else the studyset's own; `publish`'s `description`), so a studyset may introduce itself to strangers while its own stays empty in the library.
- **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/<sha256>), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). There is no export into it yet.
- **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash.
- **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.

View file

@ -1583,6 +1583,9 @@
"publishLicenceNotOpen": "Die Lizenzen dieser Lernsets erlauben keine Weitergabe: {titles}",
"publishNotes": "Was sich geändert hat",
"publishNotesHint": "Neu in dieser Veröffentlichung …",
"publishDescription": "Beschreibung auf Entdecken",
"publishDescriptionAdd": "Beschreibung hinzufügen",
"publishDescriptionHelp": "Was andere auf Entdecken sehen, bevor sie es hinzufügen. Sie beginnt mit der des Lernsets; das Lernset behält seine eigene, wie sie ist.",
"publishReleases": "Veröffentlichungen",
"publishRelease": "v{version} · {date}",
"publishButton": "v{version} veröffentlichen",
@ -1622,6 +1625,8 @@
"exploreRelease": "v{version} · {date}",
"exploreLatestRelease": "Neueste Veröffentlichung: v{version} · {date}",
"exploreAddToLibrary": "Zur Bibliothek hinzufügen",
"exploreAdd": "Hinzufügen",
"exploreInLibraryShort": "In Bibliothek",
"exploreInLibrary": "In deiner Bibliothek",
"explorePercent": "{percent} %",
"exploreAdded": "Zu deiner Bibliothek hinzugefügt.",

View file

@ -1856,6 +1856,9 @@
"@publishLicenceNotOpen": {"placeholders": {"titles": {"type": "String"}}},
"publishNotes": "What changed",
"publishNotesHint": "New in this release…",
"publishDescription": "Description on Explore",
"publishDescriptionAdd": "Add a description",
"publishDescriptionHelp": "What people see on Explore before adding it. It starts as the studyset’s own; the studyset keeps its own as it is.",
"publishReleases": "Releases",
"publishRelease": "v{version} · {date}",
"@publishRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
@ -1904,6 +1907,8 @@
"exploreLatestRelease": "Latest release: v{version} · {date}",
"@exploreLatestRelease": {"placeholders": {"version": {"type": "int"}, "date": {"type": "String"}}},
"exploreAddToLibrary": "Add to library",
"exploreAdd": "Add",
"exploreInLibraryShort": "In library",
"exploreInLibrary": "In your library",
"explorePercent": "{percent} %",
"@explorePercent": {"placeholders": {"percent": {"type": "int"}}},

View file

@ -9363,6 +9363,24 @@ abstract class AppLocalizations {
/// **'New in this release…'**
String get publishNotesHint;
/// No description provided for @publishDescription.
///
/// In en, this message translates to:
/// **'Description on Explore'**
String get publishDescription;
/// No description provided for @publishDescriptionAdd.
///
/// In en, this message translates to:
/// **'Add a description'**
String get publishDescriptionAdd;
/// No description provided for @publishDescriptionHelp.
///
/// In en, this message translates to:
/// **'What people see on Explore before adding it. It starts as the studyset’s own; the studyset keeps its own as it is.'**
String get publishDescriptionHelp;
/// No description provided for @publishReleases.
///
/// In en, this message translates to:
@ -9597,6 +9615,18 @@ abstract class AppLocalizations {
/// **'Add to library'**
String get exploreAddToLibrary;
/// No description provided for @exploreAdd.
///
/// In en, this message translates to:
/// **'Add'**
String get exploreAdd;
/// No description provided for @exploreInLibraryShort.
///
/// In en, this message translates to:
/// **'In library'**
String get exploreInLibraryShort;
/// No description provided for @exploreInLibrary.
///
/// In en, this message translates to:

View file

@ -5891,6 +5891,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get publishNotesHint => 'Neu in dieser Veröffentlichung …';
@override
String get publishDescription => 'Beschreibung auf Entdecken';
@override
String get publishDescriptionAdd => 'Beschreibung hinzufügen';
@override
String get publishDescriptionHelp =>
'Was andere auf Entdecken sehen, bevor sie es hinzufügen. Sie beginnt mit der des Lernsets; das Lernset behält seine eigene, wie sie ist.';
@override
String get publishReleases => 'Veröffentlichungen';
@ -6042,6 +6052,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get exploreAddToLibrary => 'Zur Bibliothek hinzufügen';
@override
String get exploreAdd => 'Hinzufügen';
@override
String get exploreInLibraryShort => 'In Bibliothek';
@override
String get exploreInLibrary => 'In deiner Bibliothek';

View file

@ -5840,6 +5840,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get publishNotesHint => 'New in this release…';
@override
String get publishDescription => 'Description on Explore';
@override
String get publishDescriptionAdd => 'Add a description';
@override
String get publishDescriptionHelp =>
'What people see on Explore before adding it. It starts as the studyset’s own; the studyset keeps its own as it is.';
@override
String get publishReleases => 'Releases';
@ -5989,6 +5999,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get exploreAddToLibrary => 'Add to library';
@override
String get exploreAdd => 'Add';
@override
String get exploreInLibraryShort => 'In library';
@override
String get exploreInLibrary => 'In your library';

View file

@ -84,6 +84,7 @@ class ServerpodExploreGateway(final protocol.Client _client)
frontLanguage: entry.frontLanguage,
backLanguage: entry.backLanguage,
basedOn: entry.basedOn,
colorName: entry.colorName,
license: entry.license,
licenseHolder: entry.licenseHolder,
licenseYear: entry.licenseYear,

View file

@ -33,6 +33,10 @@ final class const ExploreStudyset({
/// Where the published studyset it is a copy of is, for a copy.
final String? basedOn,
/// Its colour, by the app palette's name for it; null for none, and for
/// a release from before releases kept one.
final String? colorName,
/// Its licence and credits.
required final String license,
final String? licenseHolder,
@ -68,6 +72,7 @@ final class const ExploreStudyset({
frontLanguage,
backLanguage,
basedOn,
colorName,
license,
licenseHolder,
licenseYear,

View file

@ -8,9 +8,11 @@ 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/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.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/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.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';
@ -379,20 +381,30 @@ class const _Results({required final ExploreState state})
),
_ => AppRefreshIndicator(
onRefresh: () async => send(ExploreEventFiltered(state.filter)),
child: ListView(
// Room at the end for the navigation bar floating over the foot.
padding: EdgeInsets.fromLTRB(
12,
12,
12,
12 + MediaQuery.paddingOf(context).bottom,
),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
for (final studyset in state.studysets)
_ExploreCard(studyset: studyset, state: state),
slivers: [
SliverPadding(
padding: const EdgeInsets.all(12),
// In a grid as the library's studysets, cards up to 400 dp
// wide, every one as tall, so a row lines up.
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
mainAxisExtent: _ExploreCard.height,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: state.studysets.length,
itemBuilder: (context, index) => _ExploreCard(
studyset: state.studysets[index],
state: state,
),
),
),
if (state.hasMore)
Center(
SliverToBoxAdapter(
child: Center(
child: state.loadingMore
? const Padding(
padding: EdgeInsets.all(8),
@ -404,6 +416,13 @@ class const _Results({required final ExploreState state})
child: Text(l10n.exploreMore),
),
),
),
// Room at the end for the navigation bar floating over the foot.
SliverToBoxAdapter(
child: SizedBox(
height: 12 + MediaQuery.paddingOf(context).bottom,
),
),
],
),
),
@ -411,13 +430,18 @@ class const _Results({required final ExploreState state})
}
}
/// 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.
/// A published studyset as the library shows one of its own (decided
/// 2026-10-14): its colour along the top; its title under its secondary
/// title, the start of its description and what it holds; then, quieter,
/// who made it, which release, under which licence, and Add to library.
/// Tapping it opens its summary.
class const _ExploreCard({
required final ExploreStudyset studyset,
required final ExploreState state,
}) extends StatelessWidget {
/// Every card's height, so the grid's rows line up.
static const height = 236.0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -440,62 +464,108 @@ class const _ExploreCard({
color: theme.colorScheme.onSurfaceVariant,
);
return Card(
margin: const EdgeInsets.only(bottom: 8),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => showExploreSummary(context, studyset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(height: 4, color: exploreColor(context, studyset)),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
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 (studyset.basedOn case final original?)
Text(l10n.exploreBasedOn(original), style: muted),
TitleWithKicker(
title: studyset.title,
kicker: studyset.secondaryTitle,
titleStyle: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
titleMaxLines: 1,
reserveKickerSpace: true,
),
if (description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
Flexible(
child: Text(
description,
style: muted,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
const SizedBox(height: 4),
Text(
l10n.exploreRelease(
studyset.version,
releaseDate(context, studyset.releasedAt),
),
style: muted,
),
const Spacer(),
ExploreCounts(studyset: studyset),
if (studyset.learners case final learners?)
Text(l10n.exploreLearners(learners), style: muted),
const SizedBox(height: 12),
Row(
Text(
l10n.exploreLearners(learners),
style: muted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (studyset.basedOn case final original?)
Text(
l10n.exploreBasedOn(original),
style: muted,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
const Divider(height: 1, thickness: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 4),
child: Row(
children: [
// The chip takes what the button leaves, so the button
// ends at the edge.
Expanded(
child: Align(
alignment: AlignmentDirectional.centerStart,
child: LicenseChip(license: studyset.license),
child: Text(
[
'@${studyset.handle}',
'v${studyset.version}',
_licenseName(studyset.license),
].join(' · '),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
ExploreAddButton(studyset: studyset, state: state),
],
const SizedBox(width: 8),
ExploreAddButton(
studyset: studyset,
state: state,
compact: true,
),
],
),
),
],
),
),
);
}
}
/// [license] as people write it: CC BY-SA 4.0 for CC-BY-SA-4.0.
String _licenseName(String license) => license.startsWith('CC-')
? license
.replaceFirst('CC-', 'CC ')
.replaceFirstMapped(RegExp(r'-(\d)'), (match) => ' ${match[1]}')
: license;
/// [studyset]'s colour, as the library draws a studyset's: the palette's,
/// else the outline's.
Color exploreColor(BuildContext context, ExploreStudyset studyset) =>
PaletteColor.byName(studyset.colorName)?.background ??
Theme.of(context).colorScheme.outline;
/// [studyset]'s author as Explore names them: their display name with
/// their handle, or the handle alone.
String authorLine(ExploreStudyset studyset) => studyset.displayName.isEmpty
@ -549,11 +619,13 @@ class const LicenseChip({required final String license, super.key})
/// 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.
/// summary sheet, which either closes; [compact] on a card, as Add and In
/// library, leaving the card's line its room.
class const ExploreAddButton({
required final ExploreStudyset studyset,
required final ExploreState state,
final bool wide = false,
final bool compact = false,
super.key,
}) extends StatelessWidget {
@override
@ -561,11 +633,18 @@ class const ExploreAddButton({
final l10n = AppLocalizations.of(context);
final style = wide
? FilledButton.styleFrom(minimumSize: const Size.fromHeight(48))
: compact
? FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 12),
)
: null;
if (state.copies[studyset.publicId] case final copy?) {
return TextButton.icon(
style: wide
? TextButton.styleFrom(minimumSize: const Size.fromHeight(48))
: compact
? TextButton.styleFrom(visualDensity: VisualDensity.compact)
: null,
onPressed: () {
final router = GoRouter.of(context);
@ -575,7 +654,9 @@ class const ExploreAddButton({
router.go(RoutePaths.studyset(copy));
},
icon: const Icon(LucideIcons.check),
label: Text(l10n.exploreInLibrary),
label: Text(
compact ? l10n.exploreInLibraryShort : l10n.exploreInLibrary,
),
);
}
if (state.adding.containsKey(studyset.publicId)) {
@ -600,7 +681,7 @@ class const ExploreAddButton({
bloc.add(ExploreEventAddRequested(studyset));
},
icon: const Icon(LucideIcons.plus),
label: Text(l10n.exploreAddToLibrary),
label: Text(compact ? l10n.exploreAdd : l10n.exploreAddToLibrary),
);
}
}

View file

@ -51,6 +51,15 @@ class const _Summary({required final ExploreStudyset studyset})
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Its colour, as along the top of its card.
Container(
height: 4,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: exploreColor(context, studyset),
borderRadius: BorderRadius.circular(2),
),
),
if (studyset.secondaryTitle case final secondary?)
Text(secondary, style: theme.textTheme.titleSmall),
Text(l10n.exploreBy(authorLine(studyset)), style: muted),

View file

@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
@ -85,6 +86,7 @@ class PublishBloc(
_studysetId,
slug: event.slug,
notes: event.notes,
description: event.description,
);
}
@ -297,7 +299,11 @@ final class const PublishEventRequested({
required final String slug,
required final String notes,
required final List<String> subtree,
/// What Explore shows as its description; null to leave it to the
/// studyset's own.
final RichContent? description,
}) extends PublishEvent {
@override
List<Object?> get props => [slug, notes, subtree];
List<Object?> get props => [slug, notes, subtree, description];
}

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
/// The boundary to publishing on the instance. Tests replace it with a
@ -17,6 +18,7 @@ abstract interface class PublishGateway() {
String studysetId, {
required String slug,
required String notes,
RichContent? description,
});
/// Takes the published studyset [studysetId] off Explore.

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/published_store.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
@ -46,17 +47,20 @@ class PublishRepository(
}
/// Publishes the studyset [studysetId] as its next release, [notes]
/// saying what changed; at the first, under the name [slug]. Throws
/// saying what changed; at the first, under the name [slug]. Explore
/// shows [description] as its description, when given. Throws
/// [PublishRefused] when the instance refuses.
Future<Publication> publish(
String studysetId, {
required String slug,
required String notes,
RichContent? description,
}) async {
final publication = await _gateway.publish(
studysetId,
slug: slug.trim(),
notes: notes.trim(),
description: description,
);
await _rememberOne(studysetId, out: true);
return publication;

View file

@ -1,4 +1,7 @@
import 'dart:convert';
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/publish/data/publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
@ -24,6 +27,7 @@ class ServerpodPublishGateway(final protocol.Client _client)
String studysetId, {
required String slug,
required String notes,
RichContent? description,
}) async {
try {
return _publication(
@ -31,6 +35,10 @@ class ServerpodPublishGateway(final protocol.Client _client)
protocol.UuidValue.withValidation(studysetId),
slug,
notes,
description: switch (description) {
final description? => jsonEncode(description.toJson()),
null => null,
},
),
);
} on protocol.PublishRefusedException catch (error) {
@ -70,9 +78,26 @@ class ServerpodPublishGateway(final protocol.Client _client)
noteCount: release.noteCount,
paths: release.pathCount,
pdfs: release.pdfCount,
description: _richText(release.description),
),
],
withdrawn: publication.withdrawn,
learners: publication.learners,
);
}
/// The rich text [json] holds, as the instance keeps it; null for none, or
/// for what this app can't read.
RichContent? _richText(String? json) {
if (json == null) {
return null;
}
try {
return switch (jsonDecode(json)) {
final Map<String, Object?> content => RichContent.fromJson(content),
_ => null,
};
} on Object {
return null;
}
}

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
/// A studyset its person published (ADR 0005, rule 3): where others find it
/// ([location], `halkyon.eu/@raisondetredev/japanese`), under the name
@ -61,6 +62,10 @@ final class const Release({
final int noteCount = 0,
final int paths = 0,
final int pdfs = 0,
/// What Explore shows of it as its description; null for none, and for
/// a release made before one was given when publishing.
final RichContent? description,
}) with Equatable {
@override
List<Object?> get props => [
@ -73,6 +78,7 @@ final class const Release({
noteCount,
paths,
pdfs,
description,
];
}

View file

@ -2,9 +2,12 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.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/chevron_field.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/publish_bloc.dart';
@ -76,6 +79,31 @@ class _PublishSheetState() extends State<_PublishSheet> {
);
final _notes = TextEditingController();
/// What Explore shows as its description, once changed here; until then
/// the latest release's, else the studyset's own ([_aboutIn]).
RichContent? _about;
RichContent _aboutIn(PublishState state) =>
_about ??
switch (state) {
PublishStateReady(:final publication?)
when publication.latest?.description != null =>
publication.latest!.description!,
_ => widget.studyset.content.description,
};
Future<void> _editAbout() async {
final l10n = AppLocalizations.of(context);
final edited = await editRichText(
context,
_aboutIn(context.read<PublishBloc>().state),
title: l10n.publishDescription,
);
if (edited != null && mounted) {
setState(() => _about = edited);
}
}
@override
void dispose() {
_slug.dispose();
@ -83,13 +111,17 @@ class _PublishSheetState() extends State<_PublishSheet> {
super.dispose();
}
void _publish() => context.read<PublishBloc>().add(
void _publish() {
final bloc = context.read<PublishBloc>();
bloc.add(
PublishEventRequested(
slug: _slug.text,
notes: _notes.text,
subtree: subtreeOf(widget.studyset.id, widget.all),
description: _aboutIn(bloc.state),
),
);
}
@override
Widget build(BuildContext context) {
@ -145,6 +177,8 @@ class _PublishSheetState() extends State<_PublishSheet> {
state: ready,
slug: _slug,
notes: _notes,
about: _aboutIn(ready),
onEditAbout: _editAbout,
),
},
footer: switch (state) {
@ -175,6 +209,10 @@ class const _Form({
required final PublishStateReady state,
required final TextEditingController slug,
required final TextEditingController notes,
/// What Explore shows as its description.
required final RichContent about,
required final VoidCallback onEditAbout,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -262,6 +300,18 @@ class const _Form({
: const [],
),
const SizedBox(height: 16),
ChevronField(
label: l10n.publishDescription,
value: about.isBlank
? null
: about.plainText.trim().isNotEmpty
? about.plainText.trim()
: l10n.studysetSheetDescriptionAdded,
placeholder: l10n.publishDescriptionAdd,
helperText: l10n.publishDescriptionHelp,
onTap: state.publishing ? null : onEditAbout,
),
const SizedBox(height: 16),
TextField(
controller: notes,
minLines: 2,

View file

@ -120,12 +120,11 @@ void main() {
);
expect(find.text('Japanese'), findsOneWidget);
expect(find.text('by Alice · @alice'), findsOneWidget);
// Its author, release and licence quieter, below what it holds.
expect(find.text('Kana, kanji and grammar from N5 on.'), findsOneWidget);
expect(find.textContaining('v3 · '), findsOneWidget);
expect(find.text('812 flashcards'), findsOneWidget);
expect(find.text('CC-BY-SA-4.0'), findsOneWidget);
expect(find.text('Add to library'), findsOneWidget);
expect(find.text('@alice · v3 · CC BY-SA 4.0'), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
});
testWidgets('says how many learn it here, from 5 on, and what a copy is '
@ -268,7 +267,7 @@ void main() {
expect(copies.single.content.title, 'Japanese');
expect(copies.single.content.publishedAt, 'halkyon.eu/@alice/japanese');
expect(find.text('Added to your library.'), findsOneWidget);
expect(find.text('In your library'), findsOneWidget);
expect(find.text('In library'), findsOneWidget);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
@ -298,8 +297,8 @@ void main() {
FakeExploreGateway(published: [published('Japanese')]),
);
expect(find.text('In your library'), findsOneWidget);
expect(find.text('Add to library'), findsNothing);
expect(find.text('In library'), findsOneWidget);
expect(find.text('Add'), findsNothing);
});
testWidgets('a release in a schema this app does not know is not added, '
@ -325,7 +324,7 @@ void main() {
),
);
await tester.tap(find.text('Add to library'));
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(

View file

@ -38,13 +38,20 @@ void main() {
});
/// A studyset on this device before the app starts.
void stored(String id, String title, {String? license, String? parent}) =>
studysets.receiveRemoteChange(
void stored(
String id,
String title, {
String? license,
String? parent,
String description = '',
}) => studysets.receiveRemoteChange(
id,
encodeStudysetPayload(
StudysetContent(
title: title,
description: RichContent.empty(),
description: description.isEmpty
? RichContent.empty()
: RichContent.plain(description),
license: license,
),
),
@ -123,6 +130,26 @@ void main() {
expect(find.text('Publish v2'), findsOneWidget);
});
testWidgets("Explore's description starts as the studyset's own and goes "
'with the release; the next publish starts from it', (tester) async {
stored(
'japanese',
'Japanese',
license: 'CC-BY-SA-4.0',
description: 'From N5 on.',
);
await showStudyset(tester, 'japanese');
await openSheet(tester);
expect(find.text('Description on Explore'), findsOneWidget);
expect(find.text('From N5 on.'), findsWidgets);
await tester.tap(find.text('Publish v1'));
await tester.pumpAndSettle();
expect(publishing.descriptions.single?.plainText, 'From N5 on.');
expect(find.text('Publish v2'), findsOneWidget);
});
testWidgets('a published studyset shows a globe, its releases and where '
'it is', (tester) async {
stored('japanese', 'Japanese', license: 'CC-BY-NC-ND-4.0');
@ -322,12 +349,13 @@ void main() {
),
findsOneWidget,
);
await tester.tap(
find.descendant(
final unsettled = find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('猫'),
),
);
await tester.ensureVisible(unsettled);
await tester.pumpAndSettle();
await tester.tap(unsettled);
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsNothing);

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
@ -16,6 +17,9 @@ class FakePublishGateway({
/// What each publish was asked: the studyset, name and notes.
final publishes = <({String studysetId, String slug, String notes})>[];
/// The description for Explore each publish sent, in order.
final descriptions = <RichContent?>[];
/// When set, every request fails with it.
Exception? fail;
@ -58,11 +62,13 @@ class FakePublishGateway({
String studysetId, {
required String slug,
required String notes,
RichContent? description,
}) async {
if (fail case final error?) {
throw error;
}
publishes.add((studysetId: studysetId, slug: slug, notes: notes));
descriptions.add(description);
if (refusals.isNotEmpty) {
throw refusals.removeAt(0);
}
@ -81,6 +87,7 @@ class FakePublishGateway({
releasedAt: DateTime.utc(2026, 10, 9),
license: 'CC-BY-SA-4.0',
notes: notes,
description: description,
),
...?before?.releases,
],