feat(library): who made a studyset, and whether it is public
Each card's foot now says who made the studyset: "By you", or for a copy of a published studyset its author's handle. Beside the star, a lock or a globe says whether it is out on Explore, and opens the Publish sheet. The instance says which of the person's studysets are published and not withdrawn (`PublishEndpoint.published`, additive: older apps don't call it), and the app remembers that in the person's folder, so the library shows it offline too; publishing, withdrawing and looking one up keep it current. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
fb0ef7cdab
commit
bc3e8d9ac3
24 changed files with 729 additions and 51 deletions
|
|
@ -57,6 +57,11 @@
|
|||
"studysetArchived": "Archiviert",
|
||||
"studysetStar": "Mit Stern markieren",
|
||||
"studysetUnstar": "Markierung entfernen",
|
||||
"studysetByYou": "Von dir",
|
||||
"studysetByAuthor": "Von {author}",
|
||||
"studysetByOthers": "Eine Kopie",
|
||||
"studysetVisibilityPrivate": "Privat",
|
||||
"studysetVisibilityPublic": "Öffentlich",
|
||||
"studysetOptions": "Optionen",
|
||||
"studysetBibliography": "Literaturverzeichnis",
|
||||
"studysetBibliographyIntro": "Die in diesem Lernset zitierten Werke, im Zitierstil aus den Einstellungen.",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@
|
|||
"studysetArchived": "Archived",
|
||||
"studysetStar": "Star",
|
||||
"studysetUnstar": "Unstar",
|
||||
"studysetByYou": "By you",
|
||||
"studysetByAuthor": "By {author}",
|
||||
"@studysetByAuthor": {"placeholders": {"author": {"type": "String"}}},
|
||||
"studysetByOthers": "A copy",
|
||||
"studysetVisibilityPrivate": "Private",
|
||||
"studysetVisibilityPublic": "Public",
|
||||
"studysetOptions": "Options",
|
||||
"studysetBibliography": "Bibliography",
|
||||
"studysetBibliographyIntro": "Works cited across this studyset, in the citation style chosen in Settings.",
|
||||
|
|
|
|||
|
|
@ -400,6 +400,36 @@ abstract class AppLocalizations {
|
|||
/// **'Unstar'**
|
||||
String get studysetUnstar;
|
||||
|
||||
/// No description provided for @studysetByYou.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By you'**
|
||||
String get studysetByYou;
|
||||
|
||||
/// No description provided for @studysetByAuthor.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By {author}'**
|
||||
String studysetByAuthor(String author);
|
||||
|
||||
/// No description provided for @studysetByOthers.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'A copy'**
|
||||
String get studysetByOthers;
|
||||
|
||||
/// No description provided for @studysetVisibilityPrivate.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Private'**
|
||||
String get studysetVisibilityPrivate;
|
||||
|
||||
/// No description provided for @studysetVisibilityPublic.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Public'**
|
||||
String get studysetVisibilityPublic;
|
||||
|
||||
/// No description provided for @studysetOptions.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -219,6 +219,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studysetUnstar => 'Markierung entfernen';
|
||||
|
||||
@override
|
||||
String get studysetByYou => 'Von dir';
|
||||
|
||||
@override
|
||||
String studysetByAuthor(String author) {
|
||||
return 'Von $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetByOthers => 'Eine Kopie';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPrivate => 'Privat';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPublic => 'Öffentlich';
|
||||
|
||||
@override
|
||||
String get studysetOptions => 'Optionen';
|
||||
|
||||
|
|
|
|||
|
|
@ -217,6 +217,23 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetUnstar => 'Unstar';
|
||||
|
||||
@override
|
||||
String get studysetByYou => 'By you';
|
||||
|
||||
@override
|
||||
String studysetByAuthor(String author) {
|
||||
return 'By $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetByOthers => 'A copy';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPrivate => 'Private';
|
||||
|
||||
@override
|
||||
String get studysetVisibilityPublic => 'Public';
|
||||
|
||||
@override
|
||||
String get studysetOptions => 'Options';
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import 'package:halkyon_learn_flutter/src/feature/pomodoro/data/pomodoro_run_sto
|
|||
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/published_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/serverpod_publish_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
|
||||
|
|
@ -292,6 +293,10 @@ Future<PersonDependencies> _openPerson(
|
|||
final pathDeletions = LearningPathDeletions(paths);
|
||||
final stepDeletions = StepDeletions(paths);
|
||||
final pdfDeletions = PdfDeletions(pdfSources);
|
||||
final publishing = PublishRepository(
|
||||
ServerpodPublishGateway(client),
|
||||
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
|
||||
);
|
||||
|
||||
return PersonDependencies(
|
||||
sync: sync,
|
||||
|
|
@ -315,7 +320,7 @@ Future<PersonDependencies> _openPerson(
|
|||
File(p.join(folder.path, 'pomodoro.json')),
|
||||
),
|
||||
profile: ProfileRepository(ServerpodProfileGateway(client)),
|
||||
publishing: PublishRepository(ServerpodPublishGateway(client)),
|
||||
publishing: publishing,
|
||||
explore: ExploreRepository(
|
||||
ServerpodExploreGateway(client),
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets),
|
||||
|
|
@ -350,6 +355,7 @@ Future<PersonDependencies> _openPerson(
|
|||
await pathProgress.dispose();
|
||||
await pdfSources.dispose();
|
||||
await assets.dispose();
|
||||
await publishing.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
|
||||
|
||||
/// Which of the person's studysets are out on Explore, by id, for the
|
||||
/// library to mark them public: as the device last knew at once, then as
|
||||
/// the instance says, and every publish and withdrawal after. Only a person
|
||||
/// with an identity publishes; for anyone else the instance isn't asked.
|
||||
class PublishedCubit(
|
||||
final PublishRepository _publishing, {
|
||||
required final bool identified,
|
||||
}) extends Cubit<Set<String>> {
|
||||
this : super(const {}) {
|
||||
_subscription = _publishing.watchPublished().listen(emit);
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
late final StreamSubscription<Set<String>> _subscription;
|
||||
|
||||
/// Asks the instance again; what the device knew stands when it can't be
|
||||
/// reached.
|
||||
Future<void> refresh() async {
|
||||
if (!identified) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _publishing.refreshPublished();
|
||||
} on Object catch (error) {
|
||||
log('Asking what is published failed', name: 'Publish', error: error);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _subscription.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ abstract interface class PublishGateway() {
|
|||
/// The studyset [studysetId] as published; null while it never was.
|
||||
Future<Publication?> publication(String studysetId);
|
||||
|
||||
/// The person's studysets out on Explore (published, not withdrawn), by
|
||||
/// id.
|
||||
Future<Set<String>> published();
|
||||
|
||||
/// Publishes the studyset [studysetId] as it is on the instance as its
|
||||
/// next release; at the first, under the name [slug]. Throws
|
||||
/// [PublishRefused] when the instance refuses.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,49 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/published_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
|
||||
|
||||
/// Publishing the person's studysets (ADR 0005, rule 3). The instance
|
||||
/// builds each release from what it holds of the person's synced data, so
|
||||
/// a release has what the device last synced; publishing lives on the
|
||||
/// instance, not in the synced data, so it needs the instance to be
|
||||
/// reachable.
|
||||
class PublishRepository(final PublishGateway _gateway) {
|
||||
/// reachable. Which studysets are out on Explore the device remembers in
|
||||
/// [_store], for the library to show offline too.
|
||||
class PublishRepository(
|
||||
final PublishGateway _gateway,
|
||||
final PublishedStore _store,
|
||||
) {
|
||||
final _changes = StreamController<Set<String>>.broadcast();
|
||||
|
||||
/// The studysets out on Explore as last known; null until read.
|
||||
Set<String>? _published;
|
||||
|
||||
/// The studysets out on Explore, by id: as last known at once, then every
|
||||
/// change, whether asked of the instance ([refreshPublished]) or made
|
||||
/// here.
|
||||
Stream<Set<String>> watchPublished() => Stream.multi((listener) async {
|
||||
final subscription = _changes.stream.listen(listener.add);
|
||||
listener
|
||||
..onCancel = subscription.cancel
|
||||
..add(await _known());
|
||||
});
|
||||
|
||||
/// Asks the instance which of the person's studysets are out on Explore.
|
||||
Future<void> refreshPublished() async {
|
||||
await _remember(await _gateway.published());
|
||||
}
|
||||
|
||||
/// The studyset [studysetId] as published; null while it never was.
|
||||
Future<Publication?> publication(String studysetId) =>
|
||||
_gateway.publication(studysetId);
|
||||
Future<Publication?> publication(String studysetId) async {
|
||||
final publication = await _gateway.publication(studysetId);
|
||||
await _rememberOne(
|
||||
studysetId,
|
||||
out: publication != null && !publication.withdrawn,
|
||||
);
|
||||
return publication;
|
||||
}
|
||||
|
||||
/// Publishes the studyset [studysetId] as its next release, [notes]
|
||||
/// saying what changed; at the first, under the name [slug]. Throws
|
||||
|
|
@ -18,11 +52,57 @@ class PublishRepository(final PublishGateway _gateway) {
|
|||
String studysetId, {
|
||||
required String slug,
|
||||
required String notes,
|
||||
}) => _gateway.publish(studysetId, slug: slug.trim(), notes: notes.trim());
|
||||
}) async {
|
||||
final publication = await _gateway.publish(
|
||||
studysetId,
|
||||
slug: slug.trim(),
|
||||
notes: notes.trim(),
|
||||
);
|
||||
await _rememberOne(studysetId, out: true);
|
||||
return publication;
|
||||
}
|
||||
|
||||
/// Takes the published studyset [studysetId] off Explore: nobody can add
|
||||
/// it any more, and copies get no updates; publishing it again brings it
|
||||
/// back.
|
||||
Future<Publication> withdraw(String studysetId) =>
|
||||
_gateway.withdraw(studysetId);
|
||||
Future<Publication> withdraw(String studysetId) async {
|
||||
final publication = await _gateway.withdraw(studysetId);
|
||||
await _rememberOne(studysetId, out: false);
|
||||
return publication;
|
||||
}
|
||||
|
||||
Future<void> dispose() => _changes.close();
|
||||
|
||||
/// The studysets out on Explore as last known, read from [_store] the
|
||||
/// first time.
|
||||
Future<Set<String>> _known() async {
|
||||
if (_published case final published?) {
|
||||
return published;
|
||||
}
|
||||
final read = await _store.read();
|
||||
// Unless something changed while reading.
|
||||
return _published ??= read;
|
||||
}
|
||||
|
||||
Future<void> _rememberOne(String studysetId, {required bool out}) async {
|
||||
final published = {...await _known()};
|
||||
if (out) {
|
||||
published.add(studysetId);
|
||||
} else {
|
||||
published.remove(studysetId);
|
||||
}
|
||||
await _remember(published);
|
||||
}
|
||||
|
||||
Future<void> _remember(Set<String> published) async {
|
||||
if (setEquals(published, _published)) {
|
||||
return;
|
||||
}
|
||||
_published = published;
|
||||
// A publish may finish after the person signed out.
|
||||
if (!_changes.isClosed) {
|
||||
_changes.add(published);
|
||||
}
|
||||
await _store.write(published);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
|
||||
/// Where the device remembers which of the person's studysets are out on
|
||||
/// Explore, as the instance last said, for the library to show while the
|
||||
/// instance can't be reached.
|
||||
abstract interface class PublishedStore() {
|
||||
/// The studysets remembered, by id; none when nothing is or it can't be
|
||||
/// read.
|
||||
Future<Set<String>> read();
|
||||
|
||||
/// Remembers [ids].
|
||||
Future<void> write(Set<String> ids);
|
||||
}
|
||||
|
||||
/// Remembers them as a JSON list in [_file], in the person's folder, which
|
||||
/// goes with them; written to a temporary file first and renamed into
|
||||
/// place.
|
||||
class FilePublishedStore(final File _file) implements PublishedStore {
|
||||
// One write at a time: they share the temporary file.
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<Set<String>> read() async {
|
||||
try {
|
||||
return {
|
||||
for (final id in jsonDecode(await _file.readAsString()) as List)
|
||||
id as String,
|
||||
};
|
||||
} on FileSystemException {
|
||||
return const {};
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'The published studysets are unreadable',
|
||||
name: 'Publish',
|
||||
error: error,
|
||||
);
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Set<String> ids) => _queue.run(() async {
|
||||
await _file.parent.create(recursive: true);
|
||||
final temporary = File('${_file.path}.new');
|
||||
await temporary.writeAsString(jsonEncode([...ids]), flush: true);
|
||||
await temporary.rename(_file.path);
|
||||
});
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ class ServerpodPublishGateway(final protocol.Client _client)
|
|||
null => null,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<Set<String>> published() async => {
|
||||
for (final id in await _client.publish.published()) id.uuid,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<Publication> publish(
|
||||
String studysetId, {
|
||||
|
|
|
|||
|
|
@ -19,15 +19,16 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// Shows the Publish sheet of [studyset], one of [all] the person's
|
||||
/// studysets: whether it is published and where, its name, whether its
|
||||
/// licence allows publishing, what changed, and its releases so far, with
|
||||
/// Publish below. Publishing goes through the [PublishBloc] above
|
||||
/// [context]; without one (a person without an identity) the sheet says how
|
||||
/// to have one.
|
||||
/// Publish below. Publishing goes through [bloc], or else the [PublishBloc]
|
||||
/// above [context]; without one (a person without an identity) the sheet
|
||||
/// says how to have one.
|
||||
Future<void> showPublishSheet(
|
||||
BuildContext context, {
|
||||
required Studyset studyset,
|
||||
required List<Studyset> all,
|
||||
PublishBloc? bloc,
|
||||
}) {
|
||||
final bloc = context.read<PublishBloc?>();
|
||||
bloc ??= context.read<PublishBloc?>();
|
||||
return showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (sheetContext) => bloc == null
|
||||
|
|
|
|||
|
|
@ -25,29 +25,13 @@ class const ShareButton({
|
|||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final identified = switch (context.watch<SessionBloc>().state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
if (!identified) {
|
||||
if (!_identified(context, listen: true)) {
|
||||
return _Button(studyset: studyset, all: all, published: false);
|
||||
}
|
||||
return BlocProvider(
|
||||
// Keyed by the studyset, so another one's screen reads its own.
|
||||
key: ValueKey(studyset.id),
|
||||
create: (context) {
|
||||
final person = PersonScope.of(context);
|
||||
final sync = context.read<SyncStatusBloc>();
|
||||
return PublishBloc(
|
||||
person.publishing,
|
||||
person.profile,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
studysetId: studyset.id,
|
||||
sync: () => syncAndWait(sync),
|
||||
)..add(const PublishEventStarted());
|
||||
},
|
||||
create: (context) => _publishBloc(context, studyset.id),
|
||||
child: BlocBuilder<PublishBloc, PublishState>(
|
||||
buildWhen: (before, now) => _published(before) != _published(now),
|
||||
builder: (context, state) => _Button(
|
||||
|
|
@ -66,6 +50,49 @@ class const ShareButton({
|
|||
!state.publication!.withdrawn;
|
||||
}
|
||||
|
||||
/// Opens the Publish sheet of [studyset] where no Share button is, as from
|
||||
/// a card in the library: with a [PublishBloc] of its own while it is open.
|
||||
Future<void> showShareSheet(
|
||||
BuildContext context, {
|
||||
required Studyset studyset,
|
||||
required List<Studyset> all,
|
||||
}) async {
|
||||
if (!_identified(context)) {
|
||||
await showPublishSheet(context, studyset: studyset, all: all);
|
||||
return;
|
||||
}
|
||||
final bloc = _publishBloc(context, studyset.id);
|
||||
try {
|
||||
await showPublishSheet(context, studyset: studyset, all: all, bloc: bloc);
|
||||
} finally {
|
||||
await bloc.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the person has an identity, which publishing takes.
|
||||
bool _identified(BuildContext context, {bool listen = false}) => switch ((listen
|
||||
? context.watch<SessionBloc>()
|
||||
: context.read<SessionBloc>())
|
||||
.state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Publishing [studysetId], started.
|
||||
PublishBloc _publishBloc(BuildContext context, String studysetId) {
|
||||
final person = PersonScope.of(context);
|
||||
final sync = context.read<SyncStatusBloc>();
|
||||
return PublishBloc(
|
||||
person.publishing,
|
||||
person.profile,
|
||||
person.cards,
|
||||
person.notes,
|
||||
person.paths,
|
||||
studysetId: studysetId,
|
||||
sync: () => syncAndWait(sync),
|
||||
)..add(const PublishEventStarted());
|
||||
}
|
||||
|
||||
class const _Button({
|
||||
required final Studyset studyset,
|
||||
required final List<Studyset> all,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/published_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
|
|
@ -36,6 +39,12 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final person = PersonScope.of(context);
|
||||
final identified = context.select<SessionBloc, bool>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
return BlocProvider(
|
||||
create: (context) => StudysetListBloc(
|
||||
person.studysets,
|
||||
|
|
@ -47,7 +56,13 @@ class const LibraryScreen({super.key}) extends StatelessWidget {
|
|||
person.deletions,
|
||||
person.settings,
|
||||
)..add(const StudysetListEventStarted()),
|
||||
child: const _Library(),
|
||||
child: BlocProvider(
|
||||
// Anew once the person has an identity, which publishing takes.
|
||||
key: ValueKey(identified),
|
||||
create: (context) =>
|
||||
PublishedCubit(person.publishing, identified: identified),
|
||||
child: const _Library(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -156,9 +171,12 @@ class _LibraryState() extends State<_Library> {
|
|||
IconButton(
|
||||
tooltip: l10n.libraryRefresh,
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
onPressed: () => context.read<SyncStatusBloc>().add(
|
||||
const SyncStatusEventSyncRequested(),
|
||||
),
|
||||
onPressed: () {
|
||||
context.read<SyncStatusBloc>().add(
|
||||
const SyncStatusEventSyncRequested(),
|
||||
);
|
||||
unawaited(context.read<PublishedCubit>().refresh());
|
||||
},
|
||||
),
|
||||
if (wide)
|
||||
Padding(
|
||||
|
|
@ -293,9 +311,13 @@ class _GridState() extends State<_Grid> {
|
|||
studyset.content.title.toLowerCase().contains(query) ||
|
||||
(studyset.content.secondaryTitle?.toLowerCase().contains(query) ?? false);
|
||||
|
||||
/// The studysets out on Explore, as last known.
|
||||
var _published = const <String>{};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
_published = context.watch<PublishedCubit>().state;
|
||||
final query = widget.query.trim().toLowerCase();
|
||||
final searching = query.isNotEmpty;
|
||||
final active = [
|
||||
|
|
@ -313,7 +335,10 @@ class _GridState() extends State<_Grid> {
|
|||
final archiveOpen = searching || _archiveOpen;
|
||||
// Pulled down, the library syncs.
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
|
||||
onRefresh: () => Future.wait([
|
||||
syncAndWait(context.read<SyncStatusBloc>()),
|
||||
context.read<PublishedCubit>().refresh(),
|
||||
]),
|
||||
child: CustomScrollView(
|
||||
// Pullable however few studysets there are.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
|
|
@ -367,6 +392,13 @@ class _GridState() extends State<_Grid> {
|
|||
key: ValueKey(studyset.id),
|
||||
studyset: studyset,
|
||||
progress: widget.progress[studyset.id],
|
||||
published: _published.contains(studyset.id),
|
||||
onShare: () async {
|
||||
final all = await PersonScope.of(this.context).studysets.all();
|
||||
if (mounted) {
|
||||
await showShareSheet(this.context, studyset: studyset, all: all);
|
||||
}
|
||||
},
|
||||
// Within the library tab, so its back button returns here.
|
||||
onOpen: () => context.go(RoutePaths.studyset(studyset.id)),
|
||||
onStudy: () async {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// A studyset in the library. On top a bar in its colour; below, its title
|
||||
/// with the secondary title as a kicker, and what it holds; beside the title
|
||||
/// the action it invites, inside a ring its mature cards fill, with how many
|
||||
/// cards are due; at the bottom its star and its options.
|
||||
/// cards are due; at the bottom who made it, its star, whether it is
|
||||
/// public and its options.
|
||||
///
|
||||
/// Tapping the card opens the studyset. The action is a review when reviews
|
||||
/// are due, studying when only new cards are, and otherwise adding content,
|
||||
|
|
@ -21,6 +22,10 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// which a hovering pointer reveals and which stays in view while starred.
|
||||
/// An archived studyset rests: dimmed, in a neutral colour, paused instead
|
||||
/// of inviting anything.
|
||||
///
|
||||
/// Who made it is the person ("By you") or, in a copy of a published
|
||||
/// studyset, its author by the handle its location names. Whether it is
|
||||
/// public is a lock or a globe, which opens the Publish sheet.
|
||||
class const StudysetCard({
|
||||
/// The studyset.
|
||||
required final Studyset studyset,
|
||||
|
|
@ -28,6 +33,12 @@ class const StudysetCard({
|
|||
/// Its cards' progress; null while it is being worked out.
|
||||
required final StudyProgress? progress,
|
||||
|
||||
/// Whether it is out on Explore: published, not withdrawn.
|
||||
required final bool published,
|
||||
|
||||
/// Opens the Publish sheet.
|
||||
required final VoidCallback onShare,
|
||||
|
||||
/// Opens the studyset.
|
||||
required final VoidCallback onOpen,
|
||||
|
||||
|
|
@ -130,6 +141,9 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
),
|
||||
const Divider(height: 1, thickness: 1),
|
||||
_BottomBar(
|
||||
by: _by(AppLocalizations.of(context), studyset),
|
||||
published: widget.published,
|
||||
onShare: widget.onShare,
|
||||
starred: studyset.isStarred,
|
||||
starVisible: studyset.isStarred || _hovering,
|
||||
archived: archived,
|
||||
|
|
@ -151,6 +165,20 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Who made [studyset]: the person, or the author of the published
|
||||
/// studyset it is a copy of.
|
||||
String _by(AppLocalizations l10n, Studyset studyset) {
|
||||
final content = studyset.content;
|
||||
if (content.published == null) {
|
||||
return l10n.studysetByYou;
|
||||
}
|
||||
final handle = content.publishedAt
|
||||
?.split('/')
|
||||
.where((part) => part.startsWith('@'))
|
||||
.firstOrNull;
|
||||
return handle == null ? l10n.studysetByOthers : l10n.studysetByAuthor(handle);
|
||||
}
|
||||
|
||||
/// What the studyset holds; a muted bar of the same height while that is
|
||||
/// still being worked out, which "No content yet" would misstate.
|
||||
class const _Holds({required final StudyProgress? progress})
|
||||
|
|
@ -304,8 +332,11 @@ enum _Option() {
|
|||
delete,
|
||||
}
|
||||
|
||||
/// The star and the options.
|
||||
/// Who made it, the star, whether it is public, and the options.
|
||||
class const _BottomBar({
|
||||
required final String by,
|
||||
required final bool published,
|
||||
required final VoidCallback onShare,
|
||||
required final bool starred,
|
||||
required final bool starVisible,
|
||||
required final bool archived,
|
||||
|
|
@ -325,7 +356,12 @@ class const _BottomBar({
|
|||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: _By(by),
|
||||
),
|
||||
),
|
||||
// Its place is kept while hidden, so revealing it doesn't move the
|
||||
// options.
|
||||
IgnorePointer(
|
||||
|
|
@ -345,6 +381,13 @@ class const _BottomBar({
|
|||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(published ? LucideIcons.globe : LucideIcons.lock),
|
||||
tooltip: published
|
||||
? l10n.studysetVisibilityPublic
|
||||
: l10n.studysetVisibilityPrivate,
|
||||
onPressed: onShare,
|
||||
),
|
||||
PopupMenuButton<_Option>(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.studysetOptions,
|
||||
|
|
@ -415,3 +458,30 @@ class const _BottomBar({
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Who made the studyset, where a text button would sit: it only says.
|
||||
class const _By(final String text) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = theme.colorScheme.onSurfaceVariant;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.user, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import '../../support/fake_studyset_gateway.dart';
|
|||
import '../../support/fake_tag_gateway.dart';
|
||||
import '../../support/memory_asset_file_cache.dart';
|
||||
import '../../support/memory_pomodoro_run_store.dart';
|
||||
import '../../support/memory_published_store.dart';
|
||||
import '../../support/test_dependencies.dart';
|
||||
|
||||
/// A person's dependencies on fakes, closing with [onDispose].
|
||||
|
|
@ -80,7 +81,10 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
pdfDeletions: PdfDeletions(pdfSources),
|
||||
pomodoroRuns: MemoryPomodoroRunStore(),
|
||||
profile: ProfileRepository(FakeProfileGateway()),
|
||||
publishing: PublishRepository(FakePublishGateway()),
|
||||
publishing: PublishRepository(
|
||||
FakePublishGateway(),
|
||||
MemoryPublishedStore(),
|
||||
),
|
||||
explore: ExploreRepository(
|
||||
FakeExploreGateway(),
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import '../../support/fake_learning_path_gateway.dart';
|
|||
import '../../support/fake_note_gateway.dart';
|
||||
import '../../support/fake_profile_gateway.dart';
|
||||
import '../../support/fake_publish_gateway.dart';
|
||||
import '../../support/memory_published_store.dart';
|
||||
|
||||
RichContent text(String text) => RichContent.plain(text);
|
||||
|
||||
|
|
@ -37,7 +38,7 @@ void main() {
|
|||
});
|
||||
|
||||
PublishBloc bloc() => PublishBloc(
|
||||
PublishRepository(publishing),
|
||||
PublishRepository(publishing, MemoryPublishedStore()),
|
||||
ProfileRepository(
|
||||
FakeProfileGateway(
|
||||
profile: const PublicProfile(
|
||||
|
|
@ -108,7 +109,7 @@ void main() {
|
|||
expect(ready.outcome, PublishOutcome.published);
|
||||
expect(ready.publication!.latest!.version, 1);
|
||||
expect(calls, ['sync']);
|
||||
expect(publishing.published.single.notes, 'First lessons.');
|
||||
expect(publishing.publishes.single.notes, 'First lessons.');
|
||||
await publisher.close();
|
||||
});
|
||||
|
||||
|
|
@ -123,7 +124,7 @@ void main() {
|
|||
final ready = await publish(publisher);
|
||||
|
||||
expect(ready.outcome, PublishOutcome.published);
|
||||
expect(publishing.published, hasLength(2));
|
||||
expect(publishing.publishes, hasLength(2));
|
||||
expect(cardGateway.written.last.parents, hasLength(2));
|
||||
expect((await cards.cardsOf('japanese')).single.heads, hasLength(1));
|
||||
expect(calls, ['sync', 'sync']);
|
||||
|
|
@ -150,7 +151,7 @@ void main() {
|
|||
title: '猫',
|
||||
),
|
||||
]);
|
||||
expect(publishing.published, hasLength(1));
|
||||
expect(publishing.publishes, hasLength(1));
|
||||
expect(cardGateway.written, hasLength(written));
|
||||
await publisher.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
|
||||
|
||||
import '../../support/fake_publish_gateway.dart';
|
||||
import '../../support/memory_published_store.dart';
|
||||
|
||||
void main() {
|
||||
const japanese = Publication(
|
||||
publicId: 'p',
|
||||
slug: 'japanese',
|
||||
location: 'halkyon.eu/@raisondetredev/japanese',
|
||||
);
|
||||
|
||||
test('the studysets out on Explore: as the device remembered, then as the '
|
||||
'instance says, which the device remembers', () async {
|
||||
final store = MemoryPublishedStore({'kanji'});
|
||||
final publishing = PublishRepository(
|
||||
FakePublishGateway(publications: {'japanese': japanese}),
|
||||
store,
|
||||
);
|
||||
addTearDown(publishing.dispose);
|
||||
final seen = <Set<String>>[];
|
||||
final subscription = publishing.watchPublished().listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
await publishing.refreshPublished();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(seen, [
|
||||
{'kanji'},
|
||||
{'japanese'},
|
||||
]);
|
||||
expect(store.kept, {'japanese'});
|
||||
});
|
||||
|
||||
test('publishing adds the studyset, withdrawing takes it away, and looking '
|
||||
'one up brings what the instance says', () async {
|
||||
final gateway = FakePublishGateway(publications: {'japanese': japanese});
|
||||
final publishing = PublishRepository(gateway, MemoryPublishedStore());
|
||||
addTearDown(publishing.dispose);
|
||||
final seen = <Set<String>>[];
|
||||
final subscription = publishing.watchPublished().listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
await publishing.publication('japanese');
|
||||
await publishing.publish('kanji', slug: 'kanji', notes: '');
|
||||
await publishing.withdraw('japanese');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(seen, [
|
||||
<String>{},
|
||||
{'japanese'},
|
||||
{'japanese', 'kanji'},
|
||||
{'kanji'},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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', (
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue