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:
parent
138c4dd46e
commit
5641285d31
42 changed files with 970 additions and 85 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +628,7 @@ class const _ScopeChip({
|
||||||
onPressed: () => onReach(each),
|
onPressed: () => onReach(each),
|
||||||
child: Text(reachLongText(l10n, each)),
|
child: Text(reachLongText(l10n, each)),
|
||||||
),
|
),
|
||||||
|
if (offersPrivate) ...[
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
CheckboxMenuButton(
|
CheckboxMenuButton(
|
||||||
value: includePrivate && reach != AssistantReach.ticked,
|
value: includePrivate && reach != AssistantReach.ticked,
|
||||||
|
|
@ -623,6 +638,7 @@ class const _ScopeChip({
|
||||||
child: Text(l10n.assistantScopePrivate),
|
child: Text(l10n.assistantScopePrivate),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
builder: (context, controller, _) => FilterChip(
|
builder: (context, controller, _) => FilterChip(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
showCheckmark: false,
|
showCheckmark: false,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue