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:
Tizian 「ティツィアーン」 2026-10-10 05:17:10 +02:00
commit bc3e8d9ac3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 729 additions and 51 deletions

View file

@ -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.",

View file

@ -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.",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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 {

View file

@ -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();
}
}

View file

@ -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.

View file

@ -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);
}
}

View file

@ -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);
});
}

View file

@ -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, {

View file

@ -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

View file

@ -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,

View file

@ -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 {

View file

@ -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),
),
),
],
),
);
}
}

View file

@ -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),

View file

@ -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();
});

View file

@ -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'},
]);
});
}

View file

@ -108,7 +108,7 @@ void main() {
await tester.tap(find.text('Publish v1'));
await tester.pumpAndSettle();
expect(publishing.published.single, (
expect(publishing.publishes.single, (
studysetId: 'japanese',
slug: 'japanese',
notes: 'The first lessons.',
@ -331,7 +331,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsNothing);
expect(publishing.published, hasLength(1));
expect(publishing.publishes, hasLength(1));
});
testWidgets('on a phone it is an icon that says the studyset is private', (

View file

@ -1,18 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.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/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_auth_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_publish_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_published_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
@ -30,8 +36,16 @@ void main() {
/// How many syncs ran.
var syncs = 0;
/// Shows the library in a window [width] wide.
Future<void> showLibrary(WidgetTester tester, {double width = 1200}) async {
/// Shows the library in a window [width] wide; to a person with an
/// identity ([identified]), whose studysets [publishing] publishes and
/// [publishedStore] remembers as published.
Future<void> showLibrary(
WidgetTester tester, {
double width = 1200,
bool identified = false,
FakePublishGateway? publishing,
MemoryPublishedStore? publishedStore,
}) async {
tester.view.physicalSize = Size(width, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -39,22 +53,34 @@ void main() {
await pumpApp(
tester,
testDependencies(
authGateway: identified
? FakeAuthGateway(
status: const SignedIn(
authUserId: 'github-person',
isIdentified: true,
),
)
: null,
studysetGateway: gateway,
cardGateway: cardGateway,
files: files,
publishGateway: publishing,
publishedStore: publishedStore,
syncOnce: ({required onMerge}) async => syncs++,
),
location: RoutePaths.library,
);
}
/// A studyset on this device before the app starts.
/// A studyset on this device before the app starts; a copy of the one
/// published at [publishedAt].
void stored(
String id,
String title, {
String? secondaryTitle,
DateTime? archivedAt,
DateTime? starredAt,
String? publishedAt,
}) => gateway.receiveRemoteChange(
id,
encodeStudysetPayload(
@ -62,6 +88,10 @@ void main() {
title: title,
description: RichContent.empty(),
secondaryTitle: secondaryTitle,
published: publishedAt == null
? null
: const PublishedRef(publication: 'p', item: 'i', version: 1),
publishedAt: publishedAt,
),
),
archivedAt: archivedAt,
@ -619,4 +649,81 @@ void main() {
expect(find.byType(FloatingActionButton), findsNothing);
expect(find.text('Create studyset'), findsOneWidget);
});
group('Who made a studyset, and whether it is public', () {
Publication publication({bool withdrawn = false}) => Publication(
publicId: 'p',
slug: 'japanese',
location: 'halkyon.eu/@raisondetredev/japanese',
withdrawn: withdrawn,
);
testWidgets("the person's own says By you, a copy its author's handle", (
tester,
) async {
stored('japanese', 'Japanese');
stored('kanji', 'Kanji', publishedAt: 'halkyon.eu/@alice/kanji');
await showLibrary(tester);
expect(onCard('Japanese', find.text('By you')), findsOneWidget);
expect(onCard('Kanji', find.text('By @alice')), findsOneWidget);
});
testWidgets('a lock while private, a globe once out on Explore, as the '
'instance says', (tester) async {
stored('japanese', 'Japanese');
stored('kanji', 'Kanji');
stored('kana', 'Kana');
final store = MemoryPublishedStore();
await showLibrary(
tester,
identified: true,
publishing: FakePublishGateway(
publications: {
'japanese': publication(),
'kana': publication(withdrawn: true),
},
),
publishedStore: store,
);
expect(onCard('Japanese', find.byTooltip('Public')), findsOneWidget);
expect(onCard('Kanji', find.byTooltip('Private')), findsOneWidget);
expect(onCard('Kana', find.byTooltip('Private')), findsOneWidget);
// Remembered for when the instance can't be reached.
expect(store.kept, {'japanese'});
});
testWidgets('while the instance is out of reach, as the device last '
'knew', (tester) async {
stored('japanese', 'Japanese');
stored('kanji', 'Kanji');
await showLibrary(
tester,
identified: true,
publishing: FakePublishGateway()..fail = Exception('Offline'),
publishedStore: MemoryPublishedStore({'japanese'}),
);
expect(onCard('Japanese', find.byTooltip('Public')), findsOneWidget);
expect(onCard('Kanji', find.byTooltip('Private')), findsOneWidget);
});
testWidgets('the lock opens the Publish sheet', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await tester.tap(onCard('Japanese', find.byTooltip('Private')));
await tester.pumpAndSettle();
expect(find.text('Publish'), findsOneWidget);
expect(
find.text('Sign in with GitHub to have a public profile and publish.'),
findsOneWidget,
);
});
});
}

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