feat(library): who made a studyset, and whether it is public
Each card's foot now says who made the studyset: "By you", or for a copy of a published studyset its author's handle. Beside the star, a lock or a globe says whether it is out on Explore, and opens the Publish sheet. The instance says which of the person's studysets are published and not withdrawn (`PublishEndpoint.published`, additive: older apps don't call it), and the app remembers that in the person's folder, so the library shows it offline too; publishing, withdrawing and looking one up keep it current. 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:
parent
fb0ef7cdab
commit
bc3e8d9ac3
31 changed files with 729 additions and 51 deletions
|
|
@ -157,7 +157,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. The library's cards say who made a studyset ("By you", or a copy's author by the handle in its `publishedAt`) and whether it is out on Explore (a lock or a globe, opening the Publish sheet through `showShareSheet`): `published()` lists the person's studysets published and not withdrawn, and `PublishRepository` remembers them in `published.json` in the person's folder (`PublishedStore`), so the library shows them offline too; publishing, withdrawing and looking one up update it.
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -634,6 +634,15 @@ class EndpointPublish extends _isc.EndpointRef {
|
|||
{'studysetId': studysetId},
|
||||
);
|
||||
|
||||
/// Your studysets out on Explore: published and not withdrawn, which the
|
||||
/// library marks as public.
|
||||
_ida.Future<List<_isc.UuidValue>> published() =>
|
||||
caller.callServerEndpoint<List<_isc.UuidValue>>(
|
||||
'publish',
|
||||
'published',
|
||||
{},
|
||||
);
|
||||
|
||||
/// Publishes your studyset [studysetId] with everything inside it as its
|
||||
/// next release, [notes] saying what changed; at the first, under the
|
||||
/// name [slug] in its location, which stays its name from then on.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@
|
|||
"studysetArchived": "Archiviert",
|
||||
"studysetStar": "Mit Stern markieren",
|
||||
"studysetUnstar": "Markierung entfernen",
|
||||
"studysetByYou": "Von dir",
|
||||
"studysetByAuthor": "Von {author}",
|
||||
"studysetByOthers": "Eine Kopie",
|
||||
"studysetVisibilityPrivate": "Privat",
|
||||
"studysetVisibilityPublic": "Öffentlich",
|
||||
"studysetOptions": "Optionen",
|
||||
"studysetBibliography": "Literaturverzeichnis",
|
||||
"studysetBibliographyIntro": "Die in diesem Lernset zitierten Werke, im Zitierstil aus den Einstellungen.",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@
|
|||
"studysetArchived": "Archived",
|
||||
"studysetStar": "Star",
|
||||
"studysetUnstar": "Unstar",
|
||||
"studysetByYou": "By you",
|
||||
"studysetByAuthor": "By {author}",
|
||||
"@studysetByAuthor": {"placeholders": {"author": {"type": "String"}}},
|
||||
"studysetByOthers": "A copy",
|
||||
"studysetVisibilityPrivate": "Private",
|
||||
"studysetVisibilityPublic": "Public",
|
||||
"studysetOptions": "Options",
|
||||
"studysetBibliography": "Bibliography",
|
||||
"studysetBibliographyIntro": "Works cited across this studyset, in the citation style chosen in Settings.",
|
||||
|
|
|
|||
|
|
@ -400,6 +400,36 @@ abstract class AppLocalizations {
|
|||
/// **'Unstar'**
|
||||
String get studysetUnstar;
|
||||
|
||||
/// No description provided for @studysetByYou.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By you'**
|
||||
String get studysetByYou;
|
||||
|
||||
/// No description provided for @studysetByAuthor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By {author}'**
|
||||
String studysetByAuthor(String author);
|
||||
|
||||
/// No description provided for @studysetByOthers.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A copy'**
|
||||
String get studysetByOthers;
|
||||
|
||||
/// No description provided for @studysetVisibilityPrivate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Private'**
|
||||
String get studysetVisibilityPrivate;
|
||||
|
||||
/// No description provided for @studysetVisibilityPublic.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Public'**
|
||||
String get studysetVisibilityPublic;
|
||||
|
||||
/// No description provided for @studysetOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -219,6 +219,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studysetUnstar => 'Markierung entfernen';
|
||||
|
||||
@override
|
||||
String get studysetByYou => 'Von dir';
|
||||
|
||||
@override
|
||||
String studysetByAuthor(String author) {
|
||||
return 'Von $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetByOthers => 'Eine Kopie';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPrivate => 'Privat';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPublic => 'Öffentlich';
|
||||
|
||||
@override
|
||||
String get studysetOptions => 'Optionen';
|
||||
|
||||
|
|
|
|||
|
|
@ -217,6 +217,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetUnstar => 'Unstar';
|
||||
|
||||
@override
|
||||
String get studysetByYou => 'By you';
|
||||
|
||||
@override
|
||||
String studysetByAuthor(String author) {
|
||||
return 'By $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetByOthers => 'A copy';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPrivate => 'Private';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPublic => 'Public';
|
||||
|
||||
@override
|
||||
String get studysetOptions => 'Options';
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_sto
|
|||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/published_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/serverpod_publish_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||
|
|
@ -292,6 +293,10 @@ Future<PersonDependencies> _openPerson(
|
|||
final pathDeletions = LearningPathDeletions(paths);
|
||||
final stepDeletions = StepDeletions(paths);
|
||||
final pdfDeletions = PdfDeletions(pdfSources);
|
||||
final publishing = PublishRepository(
|
||||
ServerpodPublishGateway(client),
|
||||
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
|
||||
);
|
||||
|
||||
return PersonDependencies(
|
||||
sync: sync,
|
||||
|
|
@ -315,7 +320,7 @@ Future<PersonDependencies> _openPerson(
|
|||
File(p.join(folder.path, 'pomodoro.json')),
|
||||
),
|
||||
profile: ProfileRepository(ServerpodProfileGateway(client)),
|
||||
publishing: PublishRepository(ServerpodPublishGateway(client)),
|
||||
publishing: publishing,
|
||||
explore: ExploreRepository(
|
||||
ServerpodExploreGateway(client),
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets),
|
||||
|
|
@ -350,6 +355,7 @@ Future<PersonDependencies> _openPerson(
|
|||
await pathProgress.dispose();
|
||||
await pdfSources.dispose();
|
||||
await assets.dispose();
|
||||
await publishing.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
|
||||
|
||||
/// Which of the person's studysets are out on Explore, by id, for the
|
||||
/// library to mark them public: as the device last knew at once, then as
|
||||
/// the instance says, and every publish and withdrawal after. Only a person
|
||||
/// with an identity publishes; for anyone else the instance isn't asked.
|
||||
class PublishedCubit(
|
||||
final PublishRepository _publishing, {
|
||||
required final bool identified,
|
||||
}) extends Cubit<Set<String>> {
|
||||
this : super(const {}) {
|
||||
_subscription = _publishing.watchPublished().listen(emit);
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
late final StreamSubscription<Set<String>> _subscription;
|
||||
|
||||
/// Asks the instance again; what the device knew stands when it can't be
|
||||
/// reached.
|
||||
Future<void> refresh() async {
|
||||
if (!identified) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _publishing.refreshPublished();
|
||||
} on Object catch (error) {
|
||||
log('Asking what is published failed', name: 'Publish', error: error);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _subscription.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ abstract interface class PublishGateway() {
|
|||
/// The studyset [studysetId] as published; null while it never was.
|
||||
Future<Publication?> publication(String studysetId);
|
||||
|
||||
/// The person's studysets out on Explore (published, not withdrawn), by
|
||||
/// id.
|
||||
Future<Set<String>> published();
|
||||
|
||||
/// Publishes the studyset [studysetId] as it is on the instance as its
|
||||
/// next release; at the first, under the name [slug]. Throws
|
||||
/// [PublishRefused] when the instance refuses.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,49 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.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';
|
||||
|
||||
/// Publishing the person's studysets (ADR 0005, rule 3). The instance
|
||||
/// builds each release from what it holds of the person's synced data, so
|
||||
/// a release has what the device last synced; publishing lives on the
|
||||
/// instance, not in the synced data, so it needs the instance to be
|
||||
/// reachable.
|
||||
class PublishRepository(final PublishGateway _gateway) {
|
||||
/// reachable. Which studysets are out on Explore the device remembers in
|
||||
/// [_store], for the library to show offline too.
|
||||
class PublishRepository(
|
||||
final PublishGateway _gateway,
|
||||
final PublishedStore _store,
|
||||
) {
|
||||
final _changes = StreamController<Set<String>>.broadcast();
|
||||
|
||||
/// The studysets out on Explore as last known; null until read.
|
||||
Set<String>? _published;
|
||||
|
||||
/// The studysets out on Explore, by id: as last known at once, then every
|
||||
/// change, whether asked of the instance ([refreshPublished]) or made
|
||||
/// here.
|
||||
Stream<Set<String>> watchPublished() => Stream.multi((listener) async {
|
||||
final subscription = _changes.stream.listen(listener.add);
|
||||
listener
|
||||
..onCancel = subscription.cancel
|
||||
..add(await _known());
|
||||
});
|
||||
|
||||
/// Asks the instance which of the person's studysets are out on Explore.
|
||||
Future<void> refreshPublished() async {
|
||||
await _remember(await _gateway.published());
|
||||
}
|
||||
|
||||
/// The studyset [studysetId] as published; null while it never was.
|
||||
Future<Publication?> publication(String studysetId) =>
|
||||
_gateway.publication(studysetId);
|
||||
Future<Publication?> publication(String studysetId) async {
|
||||
final publication = await _gateway.publication(studysetId);
|
||||
await _rememberOne(
|
||||
studysetId,
|
||||
out: publication != null && !publication.withdrawn,
|
||||
);
|
||||
return publication;
|
||||
}
|
||||
|
||||
/// Publishes the studyset [studysetId] as its next release, [notes]
|
||||
/// saying what changed; at the first, under the name [slug]. Throws
|
||||
|
|
@ -18,11 +52,57 @@ class PublishRepository(final PublishGateway _gateway) {
|
|||
String studysetId, {
|
||||
required String slug,
|
||||
required String notes,
|
||||
}) => _gateway.publish(studysetId, slug: slug.trim(), notes: notes.trim());
|
||||
}) async {
|
||||
final publication = await _gateway.publish(
|
||||
studysetId,
|
||||
slug: slug.trim(),
|
||||
notes: notes.trim(),
|
||||
);
|
||||
await _rememberOne(studysetId, out: true);
|
||||
return publication;
|
||||
}
|
||||
|
||||
/// Takes the published studyset [studysetId] off Explore: nobody can add
|
||||
/// it any more, and copies get no updates; publishing it again brings it
|
||||
/// back.
|
||||
Future<Publication> withdraw(String studysetId) =>
|
||||
_gateway.withdraw(studysetId);
|
||||
Future<Publication> withdraw(String studysetId) async {
|
||||
final publication = await _gateway.withdraw(studysetId);
|
||||
await _rememberOne(studysetId, out: false);
|
||||
return publication;
|
||||
}
|
||||
|
||||
Future<void> dispose() => _changes.close();
|
||||
|
||||
/// The studysets out on Explore as last known, read from [_store] the
|
||||
/// first time.
|
||||
Future<Set<String>> _known() async {
|
||||
if (_published case final published?) {
|
||||
return published;
|
||||
}
|
||||
final read = await _store.read();
|
||||
// Unless something changed while reading.
|
||||
return _published ??= read;
|
||||
}
|
||||
|
||||
Future<void> _rememberOne(String studysetId, {required bool out}) async {
|
||||
final published = {...await _known()};
|
||||
if (out) {
|
||||
published.add(studysetId);
|
||||
} else {
|
||||
published.remove(studysetId);
|
||||
}
|
||||
await _remember(published);
|
||||
}
|
||||
|
||||
Future<void> _remember(Set<String> published) async {
|
||||
if (setEquals(published, _published)) {
|
||||
return;
|
||||
}
|
||||
_published = published;
|
||||
// A publish may finish after the person signed out.
|
||||
if (!_changes.isClosed) {
|
||||
_changes.add(published);
|
||||
}
|
||||
await _store.write(published);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
|
||||
/// Where the device remembers which of the person's studysets are out on
|
||||
/// Explore, as the instance last said, for the library to show while the
|
||||
/// instance can't be reached.
|
||||
abstract interface class PublishedStore() {
|
||||
/// The studysets remembered, by id; none when nothing is or it can't be
|
||||
/// read.
|
||||
Future<Set<String>> read();
|
||||
|
||||
/// Remembers [ids].
|
||||
Future<void> write(Set<String> ids);
|
||||
}
|
||||
|
||||
/// Remembers them as a JSON list in [_file], in the person's folder, which
|
||||
/// goes with them; written to a temporary file first and renamed into
|
||||
/// place.
|
||||
class FilePublishedStore(final File _file) implements PublishedStore {
|
||||
// One write at a time: they share the temporary file.
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<Set<String>> read() async {
|
||||
try {
|
||||
return {
|
||||
for (final id in jsonDecode(await _file.readAsString()) as List)
|
||||
id as String,
|
||||
};
|
||||
} on FileSystemException {
|
||||
return const {};
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'The published studysets are unreadable',
|
||||
name: 'Publish',
|
||||
error: error,
|
||||
);
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Set<String> ids) => _queue.run(() async {
|
||||
await _file.parent.create(recursive: true);
|
||||
final temporary = File('${_file.path}.new');
|
||||
await temporary.writeAsString(jsonEncode([...ids]), flush: true);
|
||||
await temporary.rename(_file.path);
|
||||
});
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ class ServerpodPublishGateway(final protocol.Client _client)
|
|||
null => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<Set<String>> published() async => {
|
||||
for (final id in await _client.publish.published()) id.uuid,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<Publication> publish(
|
||||
String studysetId, {
|
||||
|
|
|
|||
|
|
@ -19,15 +19,16 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// Shows the Publish sheet of [studyset], one of [all] the person's
|
||||
/// studysets: whether it is published and where, its name, whether its
|
||||
/// licence allows publishing, what changed, and its releases so far, with
|
||||
/// Publish below. Publishing goes through the [PublishBloc] above
|
||||
/// [context]; without one (a person without an identity) the sheet says how
|
||||
/// to have one.
|
||||
/// Publish below. Publishing goes through [bloc], or else the [PublishBloc]
|
||||
/// above [context]; without one (a person without an identity) the sheet
|
||||
/// says how to have one.
|
||||
Future<void> showPublishSheet(
|
||||
BuildContext context, {
|
||||
required Studyset studyset,
|
||||
required List<Studyset> all,
|
||||
PublishBloc? bloc,
|
||||
}) {
|
||||
final bloc = context.read<PublishBloc?>();
|
||||
bloc ??= context.read<PublishBloc?>();
|
||||
return showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (sheetContext) => bloc == null
|
||||
|
|
|
|||
|
|
@ -25,29 +25,13 @@ class const ShareButton({
|
|||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final identified = switch (context.watch<SessionBloc>().state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
if (!identified) {
|
||||
if (!_identified(context, listen: true)) {
|
||||
return _Button(studyset: studyset, all: all, published: false);
|
||||
}
|
||||
return BlocProvider(
|
||||
// Keyed by the studyset, so another one's screen reads its own.
|
||||
key: ValueKey(studyset.id),
|
||||
create: (context) {
|
||||
final person = PersonScope.of(context);
|
||||
final sync = context.read<SyncStatusBloc>();
|
||||
return PublishBloc(
|
||||
person.publishing,
|
||||
person.profile,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
studysetId: studyset.id,
|
||||
sync: () => syncAndWait(sync),
|
||||
)..add(const PublishEventStarted());
|
||||
},
|
||||
create: (context) => _publishBloc(context, studyset.id),
|
||||
child: BlocBuilder<PublishBloc, PublishState>(
|
||||
buildWhen: (before, now) => _published(before) != _published(now),
|
||||
builder: (context, state) => _Button(
|
||||
|
|
@ -66,6 +50,49 @@ class const ShareButton({
|
|||
!state.publication!.withdrawn;
|
||||
}
|
||||
|
||||
/// Opens the Publish sheet of [studyset] where no Share button is, as from
|
||||
/// a card in the library: with a [PublishBloc] of its own while it is open.
|
||||
Future<void> showShareSheet(
|
||||
BuildContext context, {
|
||||
required Studyset studyset,
|
||||
required List<Studyset> all,
|
||||
}) async {
|
||||
if (!_identified(context)) {
|
||||
await showPublishSheet(context, studyset: studyset, all: all);
|
||||
return;
|
||||
}
|
||||
final bloc = _publishBloc(context, studyset.id);
|
||||
try {
|
||||
await showPublishSheet(context, studyset: studyset, all: all, bloc: bloc);
|
||||
} finally {
|
||||
await bloc.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the person has an identity, which publishing takes.
|
||||
bool _identified(BuildContext context, {bool listen = false}) => switch ((listen
|
||||
? context.watch<SessionBloc>()
|
||||
: context.read<SessionBloc>())
|
||||
.state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Publishing [studysetId], started.
|
||||
PublishBloc _publishBloc(BuildContext context, String studysetId) {
|
||||
final person = PersonScope.of(context);
|
||||
final sync = context.read<SyncStatusBloc>();
|
||||
return PublishBloc(
|
||||
person.publishing,
|
||||
person.profile,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
studysetId: studysetId,
|
||||
sync: () => syncAndWait(sync),
|
||||
)..add(const PublishEventStarted());
|
||||
}
|
||||
|
||||
class const _Button({
|
||||
required final Studyset studyset,
|
||||
required final List<Studyset> all,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/published_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
|
|
@ -36,6 +39,12 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final person = PersonScope.of(context);
|
||||
final identified = context.select<SessionBloc, bool>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
return BlocProvider(
|
||||
create: (context) => StudysetListBloc(
|
||||
person.studysets,
|
||||
|
|
@ -47,7 +56,13 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
person.deletions,
|
||||
person.settings,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
child: const _Library(),
|
||||
child: BlocProvider(
|
||||
// Anew once the person has an identity, which publishing takes.
|
||||
key: ValueKey(identified),
|
||||
create: (context) =>
|
||||
PublishedCubit(person.publishing, identified: identified),
|
||||
child: const _Library(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,9 +171,12 @@ class _LibraryState() extends State<_Library> {
|
|||
IconButton(
|
||||
tooltip: l10n.libraryRefresh,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
onPressed: () => context.read<SyncStatusBloc>().add(
|
||||
const SyncStatusEventSyncRequested(),
|
||||
),
|
||||
onPressed: () {
|
||||
context.read<SyncStatusBloc>().add(
|
||||
const SyncStatusEventSyncRequested(),
|
||||
);
|
||||
unawaited(context.read<PublishedCubit>().refresh());
|
||||
},
|
||||
),
|
||||
if (wide)
|
||||
Padding(
|
||||
|
|
@ -293,9 +311,13 @@ class _GridState() extends State<_Grid> {
|
|||
studyset.content.title.toLowerCase().contains(query) ||
|
||||
(studyset.content.secondaryTitle?.toLowerCase().contains(query) ?? false);
|
||||
|
||||
/// The studysets out on Explore, as last known.
|
||||
var _published = const <String>{};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
_published = context.watch<PublishedCubit>().state;
|
||||
final query = widget.query.trim().toLowerCase();
|
||||
final searching = query.isNotEmpty;
|
||||
final active = [
|
||||
|
|
@ -313,7 +335,10 @@ class _GridState() extends State<_Grid> {
|
|||
final archiveOpen = searching || _archiveOpen;
|
||||
// Pulled down, the library syncs.
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
|
||||
onRefresh: () => Future.wait([
|
||||
syncAndWait(context.read<SyncStatusBloc>()),
|
||||
context.read<PublishedCubit>().refresh(),
|
||||
]),
|
||||
child: CustomScrollView(
|
||||
// Pullable however few studysets there are.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
|
|
@ -367,6 +392,13 @@ class _GridState() extends State<_Grid> {
|
|||
key: ValueKey(studyset.id),
|
||||
studyset: studyset,
|
||||
progress: widget.progress[studyset.id],
|
||||
published: _published.contains(studyset.id),
|
||||
onShare: () async {
|
||||
final all = await PersonScope.of(this.context).studysets.all();
|
||||
if (mounted) {
|
||||
await showShareSheet(this.context, studyset: studyset, all: all);
|
||||
}
|
||||
},
|
||||
// Within the library tab, so its back button returns here.
|
||||
onOpen: () => context.go(RoutePaths.studyset(studyset.id)),
|
||||
onStudy: () async {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// A studyset in the library. On top a bar in its colour; below, its title
|
||||
/// with the secondary title as a kicker, and what it holds; beside the title
|
||||
/// the action it invites, inside a ring its mature cards fill, with how many
|
||||
/// cards are due; at the bottom its star and its options.
|
||||
/// cards are due; at the bottom who made it, its star, whether it is
|
||||
/// public and its options.
|
||||
///
|
||||
/// Tapping the card opens the studyset. The action is a review when reviews
|
||||
/// are due, studying when only new cards are, and otherwise adding content,
|
||||
|
|
@ -21,6 +22,10 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// which a hovering pointer reveals and which stays in view while starred.
|
||||
/// An archived studyset rests: dimmed, in a neutral colour, paused instead
|
||||
/// of inviting anything.
|
||||
///
|
||||
/// Who made it is the person ("By you") or, in a copy of a published
|
||||
/// studyset, its author by the handle its location names. Whether it is
|
||||
/// public is a lock or a globe, which opens the Publish sheet.
|
||||
class const StudysetCard({
|
||||
/// The studyset.
|
||||
required final Studyset studyset,
|
||||
|
|
@ -28,6 +33,12 @@ class const StudysetCard({
|
|||
/// Its cards' progress; null while it is being worked out.
|
||||
required final StudyProgress? progress,
|
||||
|
||||
/// Whether it is out on Explore: published, not withdrawn.
|
||||
required final bool published,
|
||||
|
||||
/// Opens the Publish sheet.
|
||||
required final VoidCallback onShare,
|
||||
|
||||
/// Opens the studyset.
|
||||
required final VoidCallback onOpen,
|
||||
|
||||
|
|
@ -130,6 +141,9 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
_BottomBar(
|
||||
by: _by(AppLocalizations.of(context), studyset),
|
||||
published: widget.published,
|
||||
onShare: widget.onShare,
|
||||
starred: studyset.isStarred,
|
||||
starVisible: studyset.isStarred || _hovering,
|
||||
archived: archived,
|
||||
|
|
@ -151,6 +165,20 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Who made [studyset]: the person, or the author of the published
|
||||
/// studyset it is a copy of.
|
||||
String _by(AppLocalizations l10n, Studyset studyset) {
|
||||
final content = studyset.content;
|
||||
if (content.published == null) {
|
||||
return l10n.studysetByYou;
|
||||
}
|
||||
final handle = content.publishedAt
|
||||
?.split('/')
|
||||
.where((part) => part.startsWith('@'))
|
||||
.firstOrNull;
|
||||
return handle == null ? l10n.studysetByOthers : l10n.studysetByAuthor(handle);
|
||||
}
|
||||
|
||||
/// What the studyset holds; a muted bar of the same height while that is
|
||||
/// still being worked out, which "No content yet" would misstate.
|
||||
class const _Holds({required final StudyProgress? progress})
|
||||
|
|
@ -304,8 +332,11 @@ enum _Option() {
|
|||
delete,
|
||||
}
|
||||
|
||||
/// The star and the options.
|
||||
/// Who made it, the star, whether it is public, and the options.
|
||||
class const _BottomBar({
|
||||
required final String by,
|
||||
required final bool published,
|
||||
required final VoidCallback onShare,
|
||||
required final bool starred,
|
||||
required final bool starVisible,
|
||||
required final bool archived,
|
||||
|
|
@ -325,7 +356,12 @@ class const _BottomBar({
|
|||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: _By(by),
|
||||
),
|
||||
),
|
||||
// Its place is kept while hidden, so revealing it doesn't move the
|
||||
// options.
|
||||
IgnorePointer(
|
||||
|
|
@ -345,6 +381,13 @@ class const _BottomBar({
|
|||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(published ? LucideIcons.globe : LucideIcons.lock),
|
||||
tooltip: published
|
||||
? l10n.studysetVisibilityPublic
|
||||
: l10n.studysetVisibilityPrivate,
|
||||
onPressed: onShare,
|
||||
),
|
||||
PopupMenuButton<_Option>(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.studysetOptions,
|
||||
|
|
@ -415,3 +458,30 @@ class const _BottomBar({
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Who made the studyset, where a text button would sit: it only says.
|
||||
class const _By(final String text) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = theme.colorScheme.onSurfaceVariant;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.user, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import '../../support/fake_studyset_gateway.dart';
|
|||
import '../../support/fake_tag_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
import '../../support/memory_pomodoro_run_store.dart';
|
||||
import '../../support/memory_published_store.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
/// A person's dependencies on fakes, closing with [onDispose].
|
||||
|
|
@ -80,7 +81,10 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
pdfDeletions: PdfDeletions(pdfSources),
|
||||
pomodoroRuns: MemoryPomodoroRunStore(),
|
||||
profile: ProfileRepository(FakeProfileGateway()),
|
||||
publishing: PublishRepository(FakePublishGateway()),
|
||||
publishing: PublishRepository(
|
||||
FakePublishGateway(),
|
||||
MemoryPublishedStore(),
|
||||
),
|
||||
explore: ExploreRepository(
|
||||
FakeExploreGateway(),
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import '../../support/fake_learning_path_gateway.dart';
|
|||
import '../../support/fake_note_gateway.dart';
|
||||
import '../../support/fake_profile_gateway.dart';
|
||||
import '../../support/fake_publish_gateway.dart';
|
||||
import '../../support/memory_published_store.dart';
|
||||
|
||||
RichContent text(String text) => RichContent.plain(text);
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ void main() {
|
|||
});
|
||||
|
||||
PublishBloc bloc() => PublishBloc(
|
||||
PublishRepository(publishing),
|
||||
PublishRepository(publishing, MemoryPublishedStore()),
|
||||
ProfileRepository(
|
||||
FakeProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
|
|
@ -108,7 +109,7 @@ void main() {
|
|||
expect(ready.outcome, PublishOutcome.published);
|
||||
expect(ready.publication!.latest!.version, 1);
|
||||
expect(calls, ['sync']);
|
||||
expect(publishing.published.single.notes, 'First lessons.');
|
||||
expect(publishing.publishes.single.notes, 'First lessons.');
|
||||
await publisher.close();
|
||||
});
|
||||
|
||||
|
|
@ -123,7 +124,7 @@ void main() {
|
|||
final ready = await publish(publisher);
|
||||
|
||||
expect(ready.outcome, PublishOutcome.published);
|
||||
expect(publishing.published, hasLength(2));
|
||||
expect(publishing.publishes, hasLength(2));
|
||||
expect(cardGateway.written.last.parents, hasLength(2));
|
||||
expect((await cards.cardsOf('japanese')).single.heads, hasLength(1));
|
||||
expect(calls, ['sync', 'sync']);
|
||||
|
|
@ -150,7 +151,7 @@ void main() {
|
|||
title: '猫',
|
||||
),
|
||||
]);
|
||||
expect(publishing.published, hasLength(1));
|
||||
expect(publishing.publishes, hasLength(1));
|
||||
expect(cardGateway.written, hasLength(written));
|
||||
await publisher.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
|
||||
|
||||
import '../../support/fake_publish_gateway.dart';
|
||||
import '../../support/memory_published_store.dart';
|
||||
|
||||
void main() {
|
||||
const japanese = Publication(
|
||||
publicId: 'p',
|
||||
slug: 'japanese',
|
||||
location: 'halkyon.eu/@raisondetredev/japanese',
|
||||
);
|
||||
|
||||
test('the studysets out on Explore: as the device remembered, then as the '
|
||||
'instance says, which the device remembers', () async {
|
||||
final store = MemoryPublishedStore({'kanji'});
|
||||
final publishing = PublishRepository(
|
||||
FakePublishGateway(publications: {'japanese': japanese}),
|
||||
store,
|
||||
);
|
||||
addTearDown(publishing.dispose);
|
||||
final seen = <Set<String>>[];
|
||||
final subscription = publishing.watchPublished().listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
await publishing.refreshPublished();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(seen, [
|
||||
{'kanji'},
|
||||
{'japanese'},
|
||||
]);
|
||||
expect(store.kept, {'japanese'});
|
||||
});
|
||||
|
||||
test('publishing adds the studyset, withdrawing takes it away, and looking '
|
||||
'one up brings what the instance says', () async {
|
||||
final gateway = FakePublishGateway(publications: {'japanese': japanese});
|
||||
final publishing = PublishRepository(gateway, MemoryPublishedStore());
|
||||
addTearDown(publishing.dispose);
|
||||
final seen = <Set<String>>[];
|
||||
final subscription = publishing.watchPublished().listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
await publishing.publication('japanese');
|
||||
await publishing.publish('kanji', slug: 'kanji', notes: '');
|
||||
await publishing.withdraw('japanese');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(seen, [
|
||||
<String>{},
|
||||
{'japanese'},
|
||||
{'japanese', 'kanji'},
|
||||
{'kanji'},
|
||||
]);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue