feat(learn): Explore by what a licence allows, a skeleton while the library loads, and an assistant's agreement per provider (ADR 0005, rule 12; ADR 0021, D66)

- Explore filters by what a studyset's licence lets a learner do: adapt
  it (no NoDerivatives) and use it commercially (no NonCommercial),
  which the instance reads from the release's licence
  (ExploreQuery.adaptable, commercial) and the app the same way
  (LicenseUse). An instance from before ignores both.
- While the studysets are read, the Library shows shimmering skeletons
  of their cards rather than a spinner: halkyon_ui's new Shimmer and
  SkeletonBox, still where the system asks for no animations.
- The scope's "Include private studysets" is offered only while the
  person has a private studyset.
- An agreement to what goes where is per provider (protocol and host):
  agreeing to one assistant agrees to every assistant of that provider,
  those granted later too, and the agreement sheet says so. No
  migration: it is written on every grant of the provider, so taking
  one back keeps it for the others, and read per provider from any of
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 01:09:04 +01:00
commit 5641285d31
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
42 changed files with 970 additions and 85 deletions

File diff suppressed because one or more lines are too long

View file

@ -531,7 +531,8 @@ class EndpointDiscussion extends _isc.EndpointRef {
} }
/// Explore (ADR 0005, rule 4): the studysets published on this instance, /// Explore (ADR 0005, rule 4): the studysets published on this instance,
/// found by fit (search, languages, kinds of content), never by popularity, /// found by fit (search, languages, kinds of content, what the licence
/// allows), never by popularity,
/// the newest release first; and their releases, to learn from as a copy. /// the newest release first; and their releases, to learn from as a copy.
/// For everyone signed in: published content is public, and what a person /// For everyone signed in: published content is public, and what a person
/// takes from it stays theirs alone. A studyset its author withdrew is /// takes from it stays theirs alone. A studyset its author withdrew is

View file

@ -13,9 +13,9 @@
import 'package:serverpod_client/serverpod_client.dart' as _isc; import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What Explore is asked for: words to search titles and authors for, the /// What Explore is asked for: words to search titles and authors for, the
/// languages (a primary language, `ja`, takes its regional ones too), and /// languages (a primary language, `ja`, takes its regional ones too),
/// which kinds of content it must hold. Results come newest first, a page /// which kinds of content it must hold, and what its licence must allow.
/// at a time. /// Results come newest first, a page at a time.
abstract class ExploreQuery abstract class ExploreQuery
implements _isc.SerializableModel, _isc.ProtocolSerialization { implements _isc.SerializableModel, _isc.ProtocolSerialization {
ExploreQuery._({ ExploreQuery._({
@ -26,12 +26,16 @@ abstract class ExploreQuery
bool? withNotes, bool? withNotes,
bool? withPaths, bool? withPaths,
bool? withPdfs, bool? withPdfs,
bool? adaptable,
bool? commercial,
int? offset, int? offset,
}) : text = text ?? '', }) : text = text ?? '',
withCards = withCards ?? false, withCards = withCards ?? false,
withNotes = withNotes ?? false, withNotes = withNotes ?? false,
withPaths = withPaths ?? false, withPaths = withPaths ?? false,
withPdfs = withPdfs ?? false, withPdfs = withPdfs ?? false,
adaptable = adaptable ?? false,
commercial = commercial ?? false,
offset = offset ?? 0; offset = offset ?? 0;
factory ExploreQuery({ factory ExploreQuery({
@ -42,6 +46,8 @@ abstract class ExploreQuery
bool? withNotes, bool? withNotes,
bool? withPaths, bool? withPaths,
bool? withPdfs, bool? withPdfs,
bool? adaptable,
bool? commercial,
int? offset, int? offset,
}) = _ExploreQueryImpl; }) = _ExploreQueryImpl;
@ -62,6 +68,12 @@ abstract class ExploreQuery
withPdfs: jsonSerialization['withPdfs'] == null withPdfs: jsonSerialization['withPdfs'] == null
? null ? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['withPdfs']), : _isc.BoolJsonExtension.fromJson(jsonSerialization['withPdfs']),
adaptable: jsonSerialization['adaptable'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['adaptable']),
commercial: jsonSerialization['commercial'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['commercial']),
offset: jsonSerialization['offset'] as int?, offset: jsonSerialization['offset'] as int?,
); );
} }
@ -80,6 +92,12 @@ abstract class ExploreQuery
bool withPdfs; bool withPdfs;
/// Only licences allowing adaptations: no NoDerivatives.
bool adaptable;
/// Only licences allowing commercial use: no NonCommercial.
bool commercial;
/// How many to skip, for the next page. /// How many to skip, for the next page.
int offset; int offset;
@ -94,6 +112,8 @@ abstract class ExploreQuery
bool? withNotes, bool? withNotes,
bool? withPaths, bool? withPaths,
bool? withPdfs, bool? withPdfs,
bool? adaptable,
bool? commercial,
int? offset, int? offset,
}); });
@override @override
@ -107,6 +127,8 @@ abstract class ExploreQuery
'withNotes': withNotes, 'withNotes': withNotes,
'withPaths': withPaths, 'withPaths': withPaths,
'withPdfs': withPdfs, 'withPdfs': withPdfs,
'adaptable': adaptable,
'commercial': commercial,
'offset': offset, 'offset': offset,
}; };
} }
@ -122,6 +144,8 @@ abstract class ExploreQuery
'withNotes': withNotes, 'withNotes': withNotes,
'withPaths': withPaths, 'withPaths': withPaths,
'withPdfs': withPdfs, 'withPdfs': withPdfs,
'adaptable': adaptable,
'commercial': commercial,
'offset': offset, 'offset': offset,
}; };
} }
@ -143,6 +167,8 @@ class _ExploreQueryImpl extends ExploreQuery {
bool? withNotes, bool? withNotes,
bool? withPaths, bool? withPaths,
bool? withPdfs, bool? withPdfs,
bool? adaptable,
bool? commercial,
int? offset, int? offset,
}) : super._( }) : super._(
text: text, text: text,
@ -152,6 +178,8 @@ class _ExploreQueryImpl extends ExploreQuery {
withNotes: withNotes, withNotes: withNotes,
withPaths: withPaths, withPaths: withPaths,
withPdfs: withPdfs, withPdfs: withPdfs,
adaptable: adaptable,
commercial: commercial,
offset: offset, offset: offset,
); );
@ -167,6 +195,8 @@ class _ExploreQueryImpl extends ExploreQuery {
bool? withNotes, bool? withNotes,
bool? withPaths, bool? withPaths,
bool? withPdfs, bool? withPdfs,
bool? adaptable,
bool? commercial,
int? offset, int? offset,
}) { }) {
return ExploreQuery( return ExploreQuery(
@ -179,6 +209,8 @@ class _ExploreQueryImpl extends ExploreQuery {
withNotes: withNotes ?? this.withNotes, withNotes: withNotes ?? this.withNotes,
withPaths: withPaths ?? this.withPaths, withPaths: withPaths ?? this.withPaths,
withPdfs: withPdfs ?? this.withPdfs, withPdfs: withPdfs ?? this.withPdfs,
adaptable: adaptable ?? this.adaptable,
commercial: commercial ?? this.commercial,
offset: offset ?? this.offset, offset: offset ?? this.offset,
); );
} }

View file

@ -1642,6 +1642,13 @@
"exploreKindPaths": "Lernpfade", "exploreKindPaths": "Lernpfade",
"exploreKindPdfs": "PDFs", "exploreKindPdfs": "PDFs",
"exploreKindsIntro": "Nur Lernsets, die all das enthalten.", "exploreKindsIntro": "Nur Lernsets, die all das enthalten.",
"exploreLicense": "Lizenz",
"exploreLicenseChosen": "Lizenz: {count}",
"exploreLicenseIntro": "Nur Lernsets, deren Lizenz dir jedes davon erlaubt. Alles hier darf kopiert und weitergegeben werden.",
"exploreLicenseAdapt": "Bearbeiten",
"exploreLicenseAdaptDetail": "Verändern und das Ergebnis weitergeben: keine Lizenzen mit NoDerivatives.",
"exploreLicenseCommercial": "Kommerziell nutzen",
"exploreLicenseCommercialDetail": "Für bezahlte Arbeit oder Unterricht nutzen: keine Lizenzen mit NonCommercial.",
"exploreClearFilters": "Filter zurücksetzen", "exploreClearFilters": "Filter zurücksetzen",
"exploreNothingMatches": "Nichts Veröffentlichtes passt dazu.", "exploreNothingMatches": "Nichts Veröffentlichtes passt dazu.",
"exploreBy": "von {author}", "exploreBy": "von {author}",

View file

@ -1936,6 +1936,14 @@
"exploreKindPaths": "Learning paths", "exploreKindPaths": "Learning paths",
"exploreKindPdfs": "PDFs", "exploreKindPdfs": "PDFs",
"exploreKindsIntro": "Only studysets holding each of these.", "exploreKindsIntro": "Only studysets holding each of these.",
"exploreLicense": "Licence",
"exploreLicenseChosen": "Licence: {count}",
"@exploreLicenseChosen": {"placeholders": {"count": {"type": "int"}}},
"exploreLicenseIntro": "Only studysets whose licence lets you do each of these. Everything here may be copied and passed on.",
"exploreLicenseAdapt": "Adapt it",
"exploreLicenseAdaptDetail": "Change it and pass on what you made: no NoDerivatives licences.",
"exploreLicenseCommercial": "Use it commercially",
"exploreLicenseCommercialDetail": "Use it for paid work or teaching: no NonCommercial licences.",
"exploreClearFilters": "Clear filters", "exploreClearFilters": "Clear filters",
"exploreNothingMatches": "Nothing published here matches.", "exploreNothingMatches": "Nothing published here matches.",
"exploreBy": "by {author}", "exploreBy": "by {author}",

View file

@ -5,6 +5,11 @@
"tagSheetColor": "Color", "tagSheetColor": "Color",
"formatAlignCenter": "Center", "formatAlignCenter": "Center",
"homeCustomise": "Customize", "homeCustomise": "Customize",
"exploreLicense": "License",
"exploreLicenseChosen": "License: {count}",
"exploreLicenseIntro": "Only studysets whose license lets you do each of these. Everything here may be copied and passed on.",
"exploreLicenseAdaptDetail": "Change it and pass on what you made: no NoDerivatives licenses.",
"exploreLicenseCommercialDetail": "Use it for paid work or teaching: no NonCommercial licenses.",
"studysetSheetLicense": "License", "studysetSheetLicense": "License",
"studysetSheetLicenseLabel": "License", "studysetSheetLicenseLabel": "License",
"studysetSheetLicenseHolder": "License holder", "studysetSheetLicenseHolder": "License holder",

View file

@ -9711,6 +9711,48 @@ abstract class AppLocalizations {
/// **'Only studysets holding each of these.'** /// **'Only studysets holding each of these.'**
String get exploreKindsIntro; String get exploreKindsIntro;
/// No description provided for @exploreLicense.
///
/// In en, this message translates to:
/// **'Licence'**
String get exploreLicense;
/// No description provided for @exploreLicenseChosen.
///
/// In en, this message translates to:
/// **'Licence: {count}'**
String exploreLicenseChosen(int count);
/// No description provided for @exploreLicenseIntro.
///
/// In en, this message translates to:
/// **'Only studysets whose licence lets you do each of these. Everything here may be copied and passed on.'**
String get exploreLicenseIntro;
/// No description provided for @exploreLicenseAdapt.
///
/// In en, this message translates to:
/// **'Adapt it'**
String get exploreLicenseAdapt;
/// No description provided for @exploreLicenseAdaptDetail.
///
/// In en, this message translates to:
/// **'Change it and pass on what you made: no NoDerivatives licences.'**
String get exploreLicenseAdaptDetail;
/// No description provided for @exploreLicenseCommercial.
///
/// In en, this message translates to:
/// **'Use it commercially'**
String get exploreLicenseCommercial;
/// No description provided for @exploreLicenseCommercialDetail.
///
/// In en, this message translates to:
/// **'Use it for paid work or teaching: no NonCommercial licences.'**
String get exploreLicenseCommercialDetail;
/// No description provided for @exploreClearFilters. /// No description provided for @exploreClearFilters.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -6113,6 +6113,32 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get exploreKindsIntro => 'Nur Lernsets, die all das enthalten.'; String get exploreKindsIntro => 'Nur Lernsets, die all das enthalten.';
@override
String get exploreLicense => 'Lizenz';
@override
String exploreLicenseChosen(int count) {
return 'Lizenz: $count';
}
@override
String get exploreLicenseIntro =>
'Nur Lernsets, deren Lizenz dir jedes davon erlaubt. Alles hier darf kopiert und weitergegeben werden.';
@override
String get exploreLicenseAdapt => 'Bearbeiten';
@override
String get exploreLicenseAdaptDetail =>
'Verändern und das Ergebnis weitergeben: keine Lizenzen mit NoDerivatives.';
@override
String get exploreLicenseCommercial => 'Kommerziell nutzen';
@override
String get exploreLicenseCommercialDetail =>
'Für bezahlte Arbeit oder Unterricht nutzen: keine Lizenzen mit NonCommercial.';
@override @override
String get exploreClearFilters => 'Filter zurücksetzen'; String get exploreClearFilters => 'Filter zurücksetzen';

View file

@ -6056,6 +6056,32 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get exploreKindsIntro => 'Only studysets holding each of these.'; String get exploreKindsIntro => 'Only studysets holding each of these.';
@override
String get exploreLicense => 'Licence';
@override
String exploreLicenseChosen(int count) {
return 'Licence: $count';
}
@override
String get exploreLicenseIntro =>
'Only studysets whose licence lets you do each of these. Everything here may be copied and passed on.';
@override
String get exploreLicenseAdapt => 'Adapt it';
@override
String get exploreLicenseAdaptDetail =>
'Change it and pass on what you made: no NoDerivatives licences.';
@override
String get exploreLicenseCommercial => 'Use it commercially';
@override
String get exploreLicenseCommercialDetail =>
'Use it for paid work or teaching: no NonCommercial licences.';
@override @override
String get exploreClearFilters => 'Clear filters'; String get exploreClearFilters => 'Clear filters';
@ -8165,6 +8191,26 @@ class AppLocalizationsEnUs extends AppLocalizationsEn {
return 'These studysets\' licenses don\'t allow passing them on: $titles'; return 'These studysets\' licenses don\'t allow passing them on: $titles';
} }
@override
String get exploreLicense => 'License';
@override
String exploreLicenseChosen(int count) {
return 'License: $count';
}
@override
String get exploreLicenseIntro =>
'Only studysets whose license lets you do each of these. Everything here may be copied and passed on.';
@override
String get exploreLicenseAdaptDetail =>
'Change it and pass on what you made: no NoDerivatives licenses.';
@override
String get exploreLicenseCommercialDetail =>
'Use it for paid work or teaching: no NonCommercial licenses.';
@override @override
String get exploreReadLicence => 'Read the license'; String get exploreReadLicence => 'Read the license';

View file

@ -139,6 +139,11 @@ class _AssistantFieldState() extends State<AssistantField> {
bool? _nested; bool? _nested;
var _nestedRead = false; var _nestedRead = false;
/// Whether the person has a private studyset this device can open, so
/// that the scope offers to include private ones (decided 2026-11-01);
/// false until read.
var _hasPrivate = false;
/// The front language of the topic's studyset, what Check checks in /// The front language of the topic's studyset, what Check checks in
/// (D54); null without, or until read ([_frontRead]). /// (D54); null without, or until read ([_frontRead]).
String? _front; String? _front;
@ -152,24 +157,28 @@ class _AssistantFieldState() extends State<AssistantField> {
if (!_nestedRead) { if (!_nestedRead) {
_nestedRead = true; _nestedRead = true;
unawaited(_grammar.look()); unawaited(_grammar.look());
if (widget.topic.studysetId == null) { unawaited(_readStudysets(PersonDependencies.of(context).studysets));
_frontRead = true;
} else {
unawaited(_readNested(PersonDependencies.of(context).studysets));
}
} }
} }
Future<void> _readNested(StudysetRepository studysets) async { /// Reads, from the studysets the repository keeps, whether any is
final id = widget.topic.studysetId!; /// private, and for a topic's studyset whether it is nested and its
/// front language.
Future<void> _readStudysets(StudysetRepository studysets) async {
final id = widget.topic.studysetId;
final all = await studysets.all(); final all = await studysets.all();
final hasPrivate = all.any(
(studyset) => studyset.isPrivate && !studyset.isLocked,
);
final nested = final nested =
id != null &&
all.where((studyset) => studyset.id == id).firstOrNull?.parentId != all.where((studyset) => studyset.id == id).firstOrNull?.parentId !=
null; null;
final front = languagesOf(id, all).front; final front = id == null ? null : languagesOf(id, all).front;
if (mounted) { if (mounted) {
setState(() { setState(() {
_nested = nested; _hasPrivate = hasPrivate;
_nested = id == null ? null : nested;
_front = front; _front = front;
_frontRead = true; _frontRead = true;
}); });
@ -444,6 +453,7 @@ class _AssistantFieldState() extends State<AssistantField> {
AssistantReach.library, AssistantReach.library,
], ],
reach: _reach, reach: _reach,
offersPrivate: _hasPrivate,
includePrivate: _includePrivate, includePrivate: _includePrivate,
onReach: (reach) => onReach: (reach) =>
setState(() => _reach = reach), setState(() => _reach = reach),
@ -596,6 +606,10 @@ class const _AnswererLabel({required final Answerer answerer})
class const _ScopeChip({ class const _ScopeChip({
required final List<AssistantReach> reaches, required final List<AssistantReach> reaches,
required final AssistantReach reach, required final AssistantReach reach,
/// Whether the switch for private studysets shows: only while there is
/// one.
required final bool offersPrivate,
required final bool includePrivate, required final bool includePrivate,
required final ValueChanged<AssistantReach> onReach, required final ValueChanged<AssistantReach> onReach,
required final ValueChanged<bool> onIncludePrivate, required final ValueChanged<bool> onIncludePrivate,
@ -614,14 +628,16 @@ class const _ScopeChip({
onPressed: () => onReach(each), onPressed: () => onReach(each),
child: Text(reachLongText(l10n, each)), child: Text(reachLongText(l10n, each)),
), ),
const Divider(height: 1), if (offersPrivate) ...[
CheckboxMenuButton( const Divider(height: 1),
value: includePrivate && reach != AssistantReach.ticked, CheckboxMenuButton(
onChanged: reach == AssistantReach.ticked value: includePrivate && reach != AssistantReach.ticked,
? null onChanged: reach == AssistantReach.ticked
: (on) => onIncludePrivate(on ?? false), ? null
child: Text(l10n.assistantScopePrivate), : (on) => onIncludePrivate(on ?? false),
), child: Text(l10n.assistantScopePrivate),
),
],
], ],
builder: (context, controller, _) => FilterChip( builder: (context, controller, _) => FilterChip(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,

View file

@ -28,6 +28,8 @@ class ServerpodExploreGateway(final protocol.Client _client)
withNotes: filter.kinds.contains(ContentKind.notes), withNotes: filter.kinds.contains(ContentKind.notes),
withPaths: filter.kinds.contains(ContentKind.paths), withPaths: filter.kinds.contains(ContentKind.paths),
withPdfs: filter.kinds.contains(ContentKind.pdfs), withPdfs: filter.kinds.contains(ContentKind.pdfs),
adaptable: filter.uses.contains(LicenseUse.adapt),
commercial: filter.uses.contains(LicenseUse.commercial),
offset: offset, offset: offset,
), ),
)) ))

View file

@ -99,24 +99,40 @@ enum ContentKind() {
pdfs, pdfs,
} }
/// What a learner may do with a studyset, for Explore to look for by its
/// licence (decided 2026-11-01, as Openverse filters by: identifiers mean
/// little to a learner). Every licence published under allows passing it
/// on; only Creative Commons' identifiers forbid adapting (ND) or using
/// commercially (NC), as the instance reads them too.
enum LicenseUse(final String _forbiddenBy) {
adapt('-ND-'),
commercial('-NC-');
/// Whether [license] (an SPDX identifier, any case) allows it.
bool allowedBy(String license) =>
!license.toUpperCase().contains(_forbiddenBy);
}
/// What Explore is asked for: words to find in titles and authors, the /// 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 /// languages (a primary one, `ja`, takes its regional ones too), the kinds
/// kinds of content a studyset must hold. /// of content a studyset must hold, and what its licence must allow.
final class const ExploreFilter({ final class const ExploreFilter({
final String text = '', final String text = '',
final String? frontLanguage, final String? frontLanguage,
final String? backLanguage, final String? backLanguage,
final Set<ContentKind> kinds = const {}, final Set<ContentKind> kinds = const {},
final Set<LicenseUse> uses = const {},
}) with Equatable { }) with Equatable {
/// Whether it asks for anything but the newest. /// Whether it asks for anything but the newest.
bool get isEmpty => bool get isEmpty =>
text.trim().isEmpty && text.trim().isEmpty &&
frontLanguage == null && frontLanguage == null &&
backLanguage == null && backLanguage == null &&
kinds.isEmpty; kinds.isEmpty &&
uses.isEmpty;
@override @override
List<Object?> get props => [text, frontLanguage, backLanguage, kinds]; List<Object?> get props => [text, frontLanguage, backLanguage, kinds, uses];
} }
/// A release's content, to make a copy of or take into one: its files of /// A release's content, to make a copy of or take into one: its files of

View file

@ -19,7 +19,7 @@ import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
/// The Explore tab (ADR 0005, rule 4): the studysets published on the /// The Explore tab (ADR 0005, rule 4): the studysets published on the
/// instance, found by fit (search, languages, kinds of content), the newest /// instance, found by fit (search, languages, kinds of content, licence), the newest
/// release first, never by popularity; a page at a time, the next only when /// 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. /// asked for. Each opens a summary, and is added to the library as a copy.
class const ExploreScreen({super.key}) extends StatelessWidget { class const ExploreScreen({super.key}) extends StatelessWidget {
@ -62,6 +62,7 @@ class _ExploreState() extends State<_Explore> {
frontLanguage: filter.frontLanguage, frontLanguage: filter.frontLanguage,
backLanguage: filter.backLanguage, backLanguage: filter.backLanguage,
kinds: filter.kinds, kinds: filter.kinds,
uses: filter.uses,
), ),
), ),
); );
@ -146,8 +147,8 @@ class _ExploreState() extends State<_Explore> {
} }
} }
/// The languages and kinds of content asked for, each a chip opening its /// The languages, kinds of content and what the licence must allow, each
/// sheet. /// a chip opening its sheet.
class const _Filters({ class const _Filters({
required final ExploreFilter filter, required final ExploreFilter filter,
required final void Function( required final void Function(
@ -190,6 +191,7 @@ class const _Filters({
frontLanguage: front, frontLanguage: front,
backLanguage: back, backLanguage: back,
kinds: filter.kinds, kinds: filter.kinds,
uses: filter.uses,
), ),
), ),
), ),
@ -214,6 +216,32 @@ class const _Filters({
frontLanguage: filter.frontLanguage, frontLanguage: filter.frontLanguage,
backLanguage: filter.backLanguage, backLanguage: filter.backLanguage,
kinds: kinds, kinds: kinds,
uses: filter.uses,
),
),
),
),
),
FilterChip(
avatar: const Icon(LucideIcons.scale, size: 18),
label: Text(
filter.uses.isEmpty
? l10n.exploreLicense
: l10n.exploreLicenseChosen(filter.uses.length),
),
selected: filter.uses.isNotEmpty,
showCheckmark: false,
onSelected: (_) => showAdaptiveSheet<void>(
context,
builder: (sheetContext) => _UsesSheet(
uses: filter.uses,
onChanged: (uses) => onChanged(
(filter) => ExploreFilter(
text: filter.text,
frontLanguage: filter.frontLanguage,
backLanguage: filter.backLanguage,
kinds: filter.kinds,
uses: uses,
), ),
), ),
), ),
@ -333,6 +361,61 @@ class _KindsSheetState() extends State<_KindsSheet> {
} }
} }
/// What a studyset's licence must let a learner do, each to tick; every
/// change applies as it is made.
class const _UsesSheet({
required final Set<LicenseUse> uses,
required final ValueChanged<Set<LicenseUse>> onChanged,
}) extends StatefulWidget {
@override
State<_UsesSheet> createState() => _UsesSheetState();
}
class _UsesSheetState() extends State<_UsesSheet> {
late var _uses = widget.uses;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SheetScaffold(
title: l10n.exploreLicense,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.exploreLicenseIntro),
const SizedBox(height: 8),
for (final (use, label, detail) in [
(
LicenseUse.adapt,
l10n.exploreLicenseAdapt,
l10n.exploreLicenseAdaptDetail,
),
(
LicenseUse.commercial,
l10n.exploreLicenseCommercial,
l10n.exploreLicenseCommercialDetail,
),
])
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: Text(label),
subtitle: Text(detail),
value: _uses.contains(use),
onChanged: (on) {
setState(
() => _uses = on ?? false
? {..._uses, use}
: ({..._uses}..remove(use)),
);
widget.onChanged(_uses);
},
),
],
),
);
}
}
/// What answers the filter: a spinner, a way to try again, a word that /// 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 /// nothing does, or the studysets with Show more below while there may be
/// more. /// more.

View file

@ -286,9 +286,7 @@ class _LibraryState() extends State<_Library> {
Expanded( Expanded(
child: BlocBuilder<StudysetListBloc, StudysetListState>( child: BlocBuilder<StudysetListBloc, StudysetListState>(
builder: (context, state) => switch (state) { builder: (context, state) => switch (state) {
StudysetListStateLoading() => const Center( StudysetListStateLoading() => const _LoadingGrid(),
child: AppLoadingIndicator(),
),
StudysetListStateFailed() => const _Pullable( StudysetListStateFailed() => const _Pullable(
child: _Failed(), child: _Failed(),
), ),
@ -316,6 +314,62 @@ class _LibraryState() extends State<_Library> {
} }
} }
/// While the studysets are read: skeletons of their cards, laid out as the
/// grid lays them out, shimmering (decided 2026-11-01, as Vaia shows its
/// library loading, over a spinner).
class const _LoadingGrid() extends StatelessWidget {
@override
Widget build(BuildContext context) => Shimmer(
child: CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.all(_GridState._spacing),
sliver: SliverGrid.builder(
gridDelegate: _GridState._layout,
itemCount: 6,
itemBuilder: (context, index) => const _SkeletonCard(),
),
),
],
),
);
}
/// A studyset's card as a skeleton: its colour's strip, its title and
/// secondary title, what it holds, its progress.
class const _SkeletonCard() extends StatelessWidget {
@override
Widget build(BuildContext context) => const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(width: 40, height: 6, radius: 3),
SizedBox(height: 16),
FractionallySizedBox(
widthFactor: 0.7,
child: SkeletonBox(height: 18),
),
SizedBox(height: 8),
FractionallySizedBox(
widthFactor: 0.45,
child: SkeletonBox(height: 14),
),
Spacer(),
FractionallySizedBox(
widthFactor: 0.55,
child: SkeletonBox(height: 12),
),
SizedBox(height: 12),
SkeletonBox(height: 6, radius: 3),
],
),
),
);
}
/// The studysets couldn't be read: why, and a way to try again. /// The studysets couldn't be read: why, and a way to try again.
class const _Failed() extends StatelessWidget { class const _Failed() extends StatelessWidget {
@override @override

View file

@ -752,6 +752,22 @@ void main() {
expect(assistants.asked, hasLength(2)); expect(assistants.asked, hasLength(2));
}); });
testWidgets('without a private studyset, the scope offers no switch for '
'them', (tester) async {
studysets.receiveRemoteDeletion('diary');
await showApp(tester);
await tester.tap(
find.descendant(
of: find.byType(AssistantField),
matching: find.text('Only what I choose'),
),
);
await tester.pumpAndSettle();
expect(find.text('My whole library'), findsOneWidget);
expect(find.text('Include private studysets'), findsNothing);
});
testWidgets('a wider reach with private studysets switched on warns of ' testWidgets('a wider reach with private studysets switched on warns of '
'those within it, and sends nothing unless agreed (D8, D9)', ( 'those within it, and sends nothing unless agreed (D8, D9)', (
tester, tester,

View file

@ -37,6 +37,7 @@ ExploreStudyset published(
int? learners, int? learners,
String? basedOn, String? basedOn,
String? yours, String? yours,
String license = 'CC-BY-SA-4.0',
}) => ExploreStudyset( }) => ExploreStudyset(
publicId: publicId, publicId: publicId,
location: 'halkyon.eu/@$handle/$publicId', location: 'halkyon.eu/@$handle/$publicId',
@ -48,7 +49,7 @@ ExploreStudyset published(
title: title, title: title,
description: RichContent.plain('Kana, kanji and grammar from N5 on.'), description: RichContent.plain('Kana, kanji and grammar from N5 on.'),
frontLanguage: frontLanguage, frontLanguage: frontLanguage,
license: 'CC-BY-SA-4.0', license: license,
licenseHolder: 'Alice', licenseHolder: 'Alice',
cards: cards, cards: cards,
paths: paths, paths: paths,
@ -212,6 +213,42 @@ void main() {
expect(explore.asked.last.filter, const ExploreFilter()); expect(explore.asked.last.filter, const ExploreFilter());
}); });
testWidgets('the licence narrows to what it lets a learner do: adapt '
'it, use it commercially (decided 2026-11-01)', (tester) async {
final explore = FakeExploreGateway(
published: [
published('Open'),
published('As it is', publicId: 'as-it-is', license: 'CC-BY-ND-4.0'),
published('Not for sale', publicId: 'nc', license: 'CC-BY-NC-4.0'),
],
);
await showExplore(tester, explore);
await tester.tap(find.text('License'));
await tester.pumpAndSettle();
await tester.tap(find.text('Adapt it'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(explore.asked.last.filter.uses, {LicenseUse.adapt});
expect(find.text('As it is'), findsNothing);
expect(find.text('Not for sale'), findsOneWidget);
expect(find.text('License: 1'), findsOneWidget);
await tester.tap(find.text('License: 1'));
await tester.pumpAndSettle();
await tester.tap(find.text('Use it commercially'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(explore.asked.last.filter.uses, {
LicenseUse.adapt,
LicenseUse.commercial,
});
expect(find.text('Open'), findsOneWidget);
expect(find.text('Not for sale'), findsNothing);
});
testWidgets('shows the next page only when asked', (tester) async { testWidgets('shows the next page only when asked', (tester) async {
await showExplore( await showExplore(
tester, tester,

View file

@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
void main() {
test('a licence allows adapting unless it says NoDerivatives, and using '
'commercially unless it says NonCommercial, any version or port', () {
for (final (license, adapt, commercial) in [
('CC-BY-4.0', true, true),
('cc-by-sa-4.0', true, true),
('CC-BY-ND-4.0', false, true),
('CC-BY-NC-SA-3.0-DE', true, false),
('CC-BY-NC-ND-4.0', false, false),
('CC0-1.0', true, true),
('Public domain', true, true),
]) {
expect(LicenseUse.adapt.allowedBy(license), adapt, reason: license);
expect(
LicenseUse.commercial.allowedBy(license),
commercial,
reason: license,
);
}
});
}

View file

@ -136,6 +136,32 @@ void main() {
expect(find.text('No studysets yet'), findsOneWidget); expect(find.text('No studysets yet'), findsOneWidget);
}); });
testWidgets('while the studysets are read, the grid shows shimmering '
'skeletons of cards rather than a spinner', (tester) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
stored('japanese', 'Japanese');
final hold = Completer<void>();
gateway.holdReads = hold;
await pumpApp(
tester,
testDependencies(studysetGateway: gateway, cardGateway: cardGateway),
location: RoutePaths.library,
settle: false,
);
expect(find.byType(Shimmer), findsOneWidget);
expect(find.byType(SkeletonBox), findsWidgets);
expect(find.byType(AppLoadingIndicator), findsNothing);
gateway.holdReads = null;
hold.complete();
await tester.pumpAndSettle();
expect(find.byType(Shimmer), findsNothing);
expect(find.text('Japanese'), findsOneWidget);
});
testWidgets('says when there are no studysets yet', (tester) async { testWidgets('says when there are no studysets yet', (tester) async {
await showLibrary(tester); await showLibrary(tester);

View file

@ -49,7 +49,8 @@ class FakeExploreGateway({
(!filter.kinds.contains(ContentKind.notes) || (!filter.kinds.contains(ContentKind.notes) ||
studyset.noteCount > 0) && studyset.noteCount > 0) &&
(!filter.kinds.contains(ContentKind.paths) || studyset.paths > 0) && (!filter.kinds.contains(ContentKind.paths) || studyset.paths > 0) &&
(!filter.kinds.contains(ContentKind.pdfs) || studyset.pdfs > 0)) (!filter.kinds.contains(ContentKind.pdfs) || studyset.pdfs > 0) &&
filter.uses.every((use) => use.allowedBy(studyset.license)))
studyset, studyset,
].skip(offset).take(pageSize).toList(); ].skip(offset).take(pageSize).toList();
} }

View file

@ -24,6 +24,9 @@ class FakeStudysetGateway() implements StudysetGateway {
/// When set, reading the studysets fails with it. /// When set, reading the studysets fails with it.
Exception? failReads; Exception? failReads;
/// When set, reading the studysets waits until it completes.
Completer<void>? holdReads;
/// How often the studysets were read. /// How often the studysets were read.
int reads = 0; int reads = 0;
@ -131,6 +134,9 @@ class FakeStudysetGateway() implements StudysetGateway {
@override @override
Future<List<StudysetRow>> all() async { Future<List<StudysetRow>> all() async {
reads++; reads++;
if (holdReads case final hold?) {
await hold.future;
}
if (failReads case final error?) { if (failReads case final error?) {
throw error; throw error;
} }

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