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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,103 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/bloc/explore_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/explore_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart';
import 'package:material_ui/material_ui.dart';
/// Shows the summary of the published [studyset]: its title, author, whole
/// description, languages, what it holds, its latest release and its
/// licence with the credits; Add to library below, through the
/// [ExploreBloc] above [context].
Future<void> showExploreSummary(
BuildContext context,
ExploreStudyset studyset,
) {
final bloc = context.read<ExploreBloc>();
return showAdaptiveSheet<void>(
context,
builder: (sheetContext) => BlocProvider.value(
value: bloc,
child: _Summary(studyset: studyset),
),
);
}
class const _Summary({required final ExploreStudyset studyset})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
String? nameOf(String? tag) =>
tag == null ? null : contentLanguageName(l10n, tag);
final languages = [
?nameOf(studyset.frontLanguage),
?nameOf(studyset.backLanguage),
];
return BlocBuilder<ExploreBloc, ExploreState>(
builder: (context, state) => SheetScaffold(
title: studyset.title,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (studyset.secondaryTitle case final secondary?)
Text(secondary, style: theme.textTheme.titleSmall),
Text(l10n.exploreBy(authorLine(studyset)), style: muted),
const SizedBox(height: 12),
if (studyset.description.isBlank)
Text(
l10n.exploreNoDescription,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
)
else
RichContentView(content: studyset.description),
const SizedBox(height: 16),
if (languages.isNotEmpty)
Text(
languages.length == 2
? l10n.exploreLanguagePair(languages[0], languages[1])
: languages.single,
style: muted,
),
ExploreCounts(studyset: studyset),
const SizedBox(height: 12),
Text(
l10n.exploreLatestRelease(
studyset.version,
releaseDate(context, studyset.releasedAt),
),
style: theme.textTheme.labelLarge,
),
if (studyset.notes.isNotEmpty) ...[
const SizedBox(height: 4),
Text(studyset.notes),
],
const SizedBox(height: 12),
LicenseFooter(
content: StudysetContent(
title: studyset.title,
description: studyset.description,
license: studyset.license,
licenseHolder: studyset.licenseHolder,
licenseYear: studyset.licenseYear,
),
),
],
),
footer: ExploreAddButton(studyset: studyset, state: state, wide: true),
),
);
}
}

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