feat(explore): your own studyset is yours, not one to add
Explore offered Add on the author's own published studyset and made a whole second copy of it beside the original. The instance now names the studyset an entry was published from to its author alone (`yourStudysetId`, never to anyone else), and Explore shows it as Yours, opening the original, while it is in the library. The summary still adds a copy anyway, to see it as learners get it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
fd69719652
commit
d3ece5c528
17 changed files with 263 additions and 17 deletions
File diff suppressed because one or more lines are too long
|
|
@ -41,6 +41,7 @@ abstract class ExploreEntry
|
||||||
required this.pathCount,
|
required this.pathCount,
|
||||||
required this.pdfCount,
|
required this.pdfCount,
|
||||||
this.learners,
|
this.learners,
|
||||||
|
this.yourStudysetId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ExploreEntry({
|
factory ExploreEntry({
|
||||||
|
|
@ -68,6 +69,7 @@ abstract class ExploreEntry
|
||||||
required int pathCount,
|
required int pathCount,
|
||||||
required int pdfCount,
|
required int pdfCount,
|
||||||
int? learners,
|
int? learners,
|
||||||
|
_isc.UuidValue? yourStudysetId,
|
||||||
}) = _ExploreEntryImpl;
|
}) = _ExploreEntryImpl;
|
||||||
|
|
||||||
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -100,6 +102,11 @@ abstract class ExploreEntry
|
||||||
pathCount: jsonSerialization['pathCount'] as int,
|
pathCount: jsonSerialization['pathCount'] as int,
|
||||||
pdfCount: jsonSerialization['pdfCount'] as int,
|
pdfCount: jsonSerialization['pdfCount'] as int,
|
||||||
learners: jsonSerialization['learners'] as int?,
|
learners: jsonSerialization['learners'] as int?,
|
||||||
|
yourStudysetId: jsonSerialization['yourStudysetId'] == null
|
||||||
|
? null
|
||||||
|
: _isc.UuidValueJsonExtension.fromJson(
|
||||||
|
jsonSerialization['yourStudysetId'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +170,11 @@ abstract class ExploreEntry
|
||||||
/// null for fewer than 5, so that nobody can be told apart.
|
/// null for fewer than 5, so that nobody can be told apart.
|
||||||
int? learners;
|
int? learners;
|
||||||
|
|
||||||
|
/// For its author asking, their studyset it was published from (decided
|
||||||
|
/// 2026-10-16: Explore opens it rather than adding a copy of it); null for
|
||||||
|
/// everyone else.
|
||||||
|
_isc.UuidValue? yourStudysetId;
|
||||||
|
|
||||||
/// Returns a shallow copy of this [ExploreEntry]
|
/// Returns a shallow copy of this [ExploreEntry]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_isc.useResult
|
@_isc.useResult
|
||||||
|
|
@ -191,6 +203,7 @@ abstract class ExploreEntry
|
||||||
int? pathCount,
|
int? pathCount,
|
||||||
int? pdfCount,
|
int? pdfCount,
|
||||||
int? learners,
|
int? learners,
|
||||||
|
_isc.UuidValue? yourStudysetId,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -220,6 +233,7 @@ abstract class ExploreEntry
|
||||||
'pathCount': pathCount,
|
'pathCount': pathCount,
|
||||||
'pdfCount': pdfCount,
|
'pdfCount': pdfCount,
|
||||||
if (learners != null) 'learners': learners,
|
if (learners != null) 'learners': learners,
|
||||||
|
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,6 +265,7 @@ abstract class ExploreEntry
|
||||||
'pathCount': pathCount,
|
'pathCount': pathCount,
|
||||||
'pdfCount': pdfCount,
|
'pdfCount': pdfCount,
|
||||||
if (learners != null) 'learners': learners,
|
if (learners != null) 'learners': learners,
|
||||||
|
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,6 +303,7 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
required int pathCount,
|
required int pathCount,
|
||||||
required int pdfCount,
|
required int pdfCount,
|
||||||
int? learners,
|
int? learners,
|
||||||
|
_isc.UuidValue? yourStudysetId,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
publicId: publicId,
|
publicId: publicId,
|
||||||
location: location,
|
location: location,
|
||||||
|
|
@ -313,6 +329,7 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
pathCount: pathCount,
|
pathCount: pathCount,
|
||||||
pdfCount: pdfCount,
|
pdfCount: pdfCount,
|
||||||
learners: learners,
|
learners: learners,
|
||||||
|
yourStudysetId: yourStudysetId,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [ExploreEntry]
|
/// Returns a shallow copy of this [ExploreEntry]
|
||||||
|
|
@ -344,6 +361,7 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
int? pathCount,
|
int? pathCount,
|
||||||
int? pdfCount,
|
int? pdfCount,
|
||||||
Object? learners = _Undefined,
|
Object? learners = _Undefined,
|
||||||
|
Object? yourStudysetId = _Undefined,
|
||||||
}) {
|
}) {
|
||||||
return ExploreEntry(
|
return ExploreEntry(
|
||||||
publicId: publicId ?? this.publicId,
|
publicId: publicId ?? this.publicId,
|
||||||
|
|
@ -376,6 +394,9 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
pathCount: pathCount ?? this.pathCount,
|
pathCount: pathCount ?? this.pathCount,
|
||||||
pdfCount: pdfCount ?? this.pdfCount,
|
pdfCount: pdfCount ?? this.pdfCount,
|
||||||
learners: learners is int? ? learners : this.learners,
|
learners: learners is int? ? learners : this.learners,
|
||||||
|
yourStudysetId: yourStudysetId is _isc.UuidValue?
|
||||||
|
? yourStudysetId
|
||||||
|
: this.yourStudysetId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1697,6 +1697,8 @@
|
||||||
"exploreAdd": "Hinzufügen",
|
"exploreAdd": "Hinzufügen",
|
||||||
"exploreInLibraryShort": "In Bibliothek",
|
"exploreInLibraryShort": "In Bibliothek",
|
||||||
"exploreInLibrary": "In deiner Bibliothek",
|
"exploreInLibrary": "In deiner Bibliothek",
|
||||||
|
"exploreYours": "Deins",
|
||||||
|
"exploreAddCopyAnyway": "Trotzdem eine Kopie hinzufügen",
|
||||||
"explorePercent": "{percent} %",
|
"explorePercent": "{percent} %",
|
||||||
"exploreAdded": "Zu deiner Bibliothek hinzugefügt.",
|
"exploreAdded": "Zu deiner Bibliothek hinzugefügt.",
|
||||||
"exploreOpen": "Öffnen",
|
"exploreOpen": "Öffnen",
|
||||||
|
|
|
||||||
|
|
@ -1997,6 +1997,8 @@
|
||||||
"exploreAdd": "Add",
|
"exploreAdd": "Add",
|
||||||
"exploreInLibraryShort": "In library",
|
"exploreInLibraryShort": "In library",
|
||||||
"exploreInLibrary": "In your library",
|
"exploreInLibrary": "In your library",
|
||||||
|
"exploreYours": "Yours",
|
||||||
|
"exploreAddCopyAnyway": "Add a copy anyway",
|
||||||
"explorePercent": "{percent} %",
|
"explorePercent": "{percent} %",
|
||||||
"@explorePercent": {"placeholders": {"percent": {"type": "int"}}},
|
"@explorePercent": {"placeholders": {"percent": {"type": "int"}}},
|
||||||
"exploreAdded": "Added to your library.",
|
"exploreAdded": "Added to your library.",
|
||||||
|
|
|
||||||
|
|
@ -10047,6 +10047,18 @@ abstract class AppLocalizations {
|
||||||
/// **'In your library'**
|
/// **'In your library'**
|
||||||
String get exploreInLibrary;
|
String get exploreInLibrary;
|
||||||
|
|
||||||
|
/// No description provided for @exploreYours.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Yours'**
|
||||||
|
String get exploreYours;
|
||||||
|
|
||||||
|
/// No description provided for @exploreAddCopyAnyway.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add a copy anyway'**
|
||||||
|
String get exploreAddCopyAnyway;
|
||||||
|
|
||||||
/// No description provided for @explorePercent.
|
/// No description provided for @explorePercent.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -6333,6 +6333,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get exploreInLibrary => 'In deiner Bibliothek';
|
String get exploreInLibrary => 'In deiner Bibliothek';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exploreYours => 'Deins';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exploreAddCopyAnyway => 'Trotzdem eine Kopie hinzufügen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String explorePercent(int percent) {
|
String explorePercent(int percent) {
|
||||||
return '$percent %';
|
return '$percent %';
|
||||||
|
|
|
||||||
|
|
@ -6276,6 +6276,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get exploreInLibrary => 'In your library';
|
String get exploreInLibrary => 'In your library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exploreYours => 'Yours';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exploreAddCopyAnyway => 'Add a copy anyway';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String explorePercent(int percent) {
|
String explorePercent(int percent) {
|
||||||
return '$percent %';
|
return '$percent %';
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,9 @@ class ExploreBloc(final ExploreRepository _explore)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
final (copies, (:page, :hasMore)) = await (
|
final (copies, held, (:page, :hasMore)) = await (
|
||||||
_explore.copies(),
|
_explore.copies(),
|
||||||
|
_explore.held(),
|
||||||
_explore.list(event.filter),
|
_explore.list(event.filter),
|
||||||
).wait;
|
).wait;
|
||||||
if (query != _query) {
|
if (query != _query) {
|
||||||
|
|
@ -48,6 +49,7 @@ class ExploreBloc(final ExploreRepository _explore)
|
||||||
studysets: page,
|
studysets: page,
|
||||||
hasMore: hasMore,
|
hasMore: hasMore,
|
||||||
copies: copies,
|
copies: copies,
|
||||||
|
held: held,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
|
|
@ -178,7 +180,9 @@ final class const ExploreNotWritten() extends ExploreOutcome;
|
||||||
|
|
||||||
/// Explore as its screen shows it: the [filter] and what answers it, a
|
/// Explore as its screen shows it: the [filter] and what answers it, a
|
||||||
/// page at a time, which of them the person has a copy of ([copies], by
|
/// page at a time, which of them the person has a copy of ([copies], by
|
||||||
/// public id, the copy's top studyset), which are being added and how far
|
/// public id, the copy's top studyset), which studysets are in their
|
||||||
|
/// library ([held]: one of theirs Explore lists opens rather than being
|
||||||
|
/// added again), which are being added and how far
|
||||||
/// ([adding], null before the first step), and what the last request came
|
/// ([adding], null before the first step), and what the last request came
|
||||||
/// to ([outcome], cleared by the next state).
|
/// to ([outcome], cleared by the next state).
|
||||||
final class const ExploreState({
|
final class const ExploreState({
|
||||||
|
|
@ -188,9 +192,17 @@ final class const ExploreState({
|
||||||
final bool hasMore = false,
|
final bool hasMore = false,
|
||||||
final bool loadingMore = false,
|
final bool loadingMore = false,
|
||||||
final Map<String, String> copies = const {},
|
final Map<String, String> copies = const {},
|
||||||
|
final Set<String> held = const {},
|
||||||
final Map<String, double?> adding = const {},
|
final Map<String, double?> adding = const {},
|
||||||
final ExploreOutcome? outcome,
|
final ExploreOutcome? outcome,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
|
/// The person's own studyset [studyset] was published from, while it is
|
||||||
|
/// in their library; null for anyone else's.
|
||||||
|
String? yoursOf(ExploreStudyset studyset) => switch (studyset.yours) {
|
||||||
|
final id? when held.contains(id) => id,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
/// This state with what is given changed, and no outcome unless given.
|
/// This state with what is given changed, and no outcome unless given.
|
||||||
ExploreState copyWith({
|
ExploreState copyWith({
|
||||||
ExploreStatus? status,
|
ExploreStatus? status,
|
||||||
|
|
@ -199,6 +211,7 @@ final class const ExploreState({
|
||||||
bool? hasMore,
|
bool? hasMore,
|
||||||
bool? loadingMore,
|
bool? loadingMore,
|
||||||
Map<String, String>? copies,
|
Map<String, String>? copies,
|
||||||
|
Set<String>? held,
|
||||||
Map<String, double?>? adding,
|
Map<String, double?>? adding,
|
||||||
ExploreOutcome? outcome,
|
ExploreOutcome? outcome,
|
||||||
}) => ExploreState(
|
}) => ExploreState(
|
||||||
|
|
@ -208,6 +221,7 @@ final class const ExploreState({
|
||||||
hasMore: hasMore ?? this.hasMore,
|
hasMore: hasMore ?? this.hasMore,
|
||||||
loadingMore: loadingMore ?? this.loadingMore,
|
loadingMore: loadingMore ?? this.loadingMore,
|
||||||
copies: copies ?? this.copies,
|
copies: copies ?? this.copies,
|
||||||
|
held: held ?? this.held,
|
||||||
adding: adding ?? this.adding,
|
adding: adding ?? this.adding,
|
||||||
outcome: outcome,
|
outcome: outcome,
|
||||||
);
|
);
|
||||||
|
|
@ -220,6 +234,7 @@ final class const ExploreState({
|
||||||
hasMore,
|
hasMore,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
copies,
|
copies,
|
||||||
|
held,
|
||||||
adding,
|
adding,
|
||||||
outcome,
|
outcome,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ class ExploreRepository(
|
||||||
publication: studyset.id,
|
publication: studyset.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The ids of the person's studysets in their library, those deleted
|
||||||
|
/// left out: whether one Explore names as theirs is still there.
|
||||||
|
Future<Set<String>> held() async => {
|
||||||
|
for (final studyset in await _studysets.all()) studyset.id,
|
||||||
|
};
|
||||||
|
|
||||||
/// What the latest release of the published studyset the person's copy
|
/// What the latest release of the published studyset the person's copy
|
||||||
/// [rootId] (its top studyset) came from would bring it; null while the
|
/// [rootId] (its top studyset) came from would bring it; null while the
|
||||||
/// copy holds the latest, and for a studyset its author withdrew, whose
|
/// copy holds the latest, and for a studyset its author withdrew, whose
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,7 @@ class ServerpodExploreGateway(final protocol.Client _client)
|
||||||
paths: entry.pathCount,
|
paths: entry.pathCount,
|
||||||
pdfs: entry.pdfCount,
|
pdfs: entry.pdfCount,
|
||||||
learners: entry.learners,
|
learners: entry.learners,
|
||||||
|
yours: entry.yourStudysetId?.uuid,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// A description as the instance keeps it, its rich text's JSON; empty
|
/// A description as the instance keeps it, its rich text's JSON; empty
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ final class const ExploreStudyset({
|
||||||
/// How many people learn it here, rounded (5, 10, 20, 50, …); null for
|
/// How many people learn it here, rounded (5, 10, 20, 50, …); null for
|
||||||
/// fewer than 5 (ADR 0005: shown, never ranked).
|
/// fewer than 5 (ADR 0005: shown, never ranked).
|
||||||
final int? learners,
|
final int? learners,
|
||||||
|
|
||||||
|
/// For its author, their studyset it was published from (decided
|
||||||
|
/// 2026-10-16: Explore opens it rather than adding a copy); null for
|
||||||
|
/// everyone else, and from instances before v0.18.
|
||||||
|
final String? yours,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// Its author as they are shown: their display name, else their handle.
|
/// Its author as they are shown: their display name, else their handle.
|
||||||
String get author => displayName.isEmpty ? '@$handle' : displayName;
|
String get author => displayName.isEmpty ? '@$handle' : displayName;
|
||||||
|
|
@ -82,6 +87,7 @@ final class const ExploreStudyset({
|
||||||
paths,
|
paths,
|
||||||
pdfs,
|
pdfs,
|
||||||
learners,
|
learners,
|
||||||
|
yours,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -626,9 +626,11 @@ class const LicenseChip({required final String license, super.key})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add to library for [studyset]: its progress while it is being added, In
|
/// Add to library for [studyset]: its progress while it is being added, In
|
||||||
/// your library (opening the copy) once there is a copy. [wide] in the
|
/// your library (opening the copy) once there is a copy, and Yours (opening
|
||||||
/// summary sheet, which either closes; [compact] on a card, as Add and In
|
/// it) for the person's own (decided 2026-10-16), the summary also adding a
|
||||||
/// library, leaving the card's line its room.
|
/// copy anyway, to see it as learners get it. [wide] in the summary sheet,
|
||||||
|
/// which either closes; [compact] on a card, as Add and In library, leaving
|
||||||
|
/// the card's line its room.
|
||||||
class const ExploreAddButton({
|
class const ExploreAddButton({
|
||||||
required final ExploreStudyset studyset,
|
required final ExploreStudyset studyset,
|
||||||
required final ExploreState state,
|
required final ExploreState state,
|
||||||
|
|
@ -647,6 +649,48 @@ class const ExploreAddButton({
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
void add() {
|
||||||
|
final bloc = context.read<ExploreBloc>();
|
||||||
|
// From the summary, it goes on in the list: its progress shows on
|
||||||
|
// the studyset's card, and what came of it below the list.
|
||||||
|
if (wide) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
bloc.add(ExploreEventAddRequested(studyset));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.yoursOf(studyset) case final own?) {
|
||||||
|
final open = TextButton.icon(
|
||||||
|
style: wide
|
||||||
|
? TextButton.styleFrom(minimumSize: const Size.fromHeight(48))
|
||||||
|
: compact
|
||||||
|
? TextButton.styleFrom(visualDensity: VisualDensity.compact)
|
||||||
|
: null,
|
||||||
|
onPressed: () {
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
if (wide) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
router.go(RoutePaths.studyset(own));
|
||||||
|
},
|
||||||
|
icon: const Icon(LucideIcons.user),
|
||||||
|
label: Text(l10n.exploreYours),
|
||||||
|
);
|
||||||
|
if (!wide || state.copies.containsKey(studyset.publicId)) {
|
||||||
|
return open;
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
open,
|
||||||
|
if (state.adding.containsKey(studyset.publicId))
|
||||||
|
const Center(child: ButtonLoadingIndicator())
|
||||||
|
else
|
||||||
|
TextButton(onPressed: add, child: Text(l10n.exploreAddCopyAnyway)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
if (state.copies[studyset.publicId] case final copy?) {
|
if (state.copies[studyset.publicId] case final copy?) {
|
||||||
return TextButton.icon(
|
return TextButton.icon(
|
||||||
style: wide
|
style: wide
|
||||||
|
|
@ -679,15 +723,7 @@ class const ExploreAddButton({
|
||||||
}
|
}
|
||||||
return FilledButton.tonalIcon(
|
return FilledButton.tonalIcon(
|
||||||
style: style,
|
style: style,
|
||||||
onPressed: () {
|
onPressed: add,
|
||||||
final bloc = context.read<ExploreBloc>();
|
|
||||||
// From the summary, it goes on in the list: its progress shows on
|
|
||||||
// the studyset's card, and what came of it below the list.
|
|
||||||
if (wide) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
bloc.add(ExploreEventAddRequested(studyset));
|
|
||||||
},
|
|
||||||
icon: const Icon(LucideIcons.plus),
|
icon: const Icon(LucideIcons.plus),
|
||||||
label: Text(compact ? l10n.exploreAdd : l10n.exploreAddToLibrary),
|
label: Text(compact ? l10n.exploreAdd : l10n.exploreAddToLibrary),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ ExploreStudyset published(
|
||||||
int paths = 0,
|
int paths = 0,
|
||||||
int? learners,
|
int? learners,
|
||||||
String? basedOn,
|
String? basedOn,
|
||||||
|
String? yours,
|
||||||
}) => ExploreStudyset(
|
}) => ExploreStudyset(
|
||||||
publicId: publicId,
|
publicId: publicId,
|
||||||
location: 'halkyon.eu/@$handle/$publicId',
|
location: 'halkyon.eu/@$handle/$publicId',
|
||||||
|
|
@ -53,6 +54,7 @@ ExploreStudyset published(
|
||||||
paths: paths,
|
paths: paths,
|
||||||
learners: learners,
|
learners: learners,
|
||||||
basedOn: basedOn,
|
basedOn: basedOn,
|
||||||
|
yours: yours,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The JSON a synced [payload] holds.
|
/// The JSON a synced [payload] holds.
|
||||||
|
|
@ -366,6 +368,64 @@ void main() {
|
||||||
expect(find.text('Add'), findsNothing);
|
expect(find.text('Add'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group("the person's own studyset", () {
|
||||||
|
setUp(
|
||||||
|
() => studysets.receiveRemoteChange(
|
||||||
|
'mine',
|
||||||
|
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('shows as theirs and opens, adding no copy; the summary adds '
|
||||||
|
'a copy anyway when asked', (tester) async {
|
||||||
|
await showExplore(
|
||||||
|
tester,
|
||||||
|
FakeExploreGateway(
|
||||||
|
published: [published('Japanese', yours: 'mine')],
|
||||||
|
releases: [release('japanese', 'Japanese')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('Add'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Yours'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(StudysetScreen), findsOneWidget);
|
||||||
|
expect(await StudysetRepository(studysets).all(), hasLength(1));
|
||||||
|
|
||||||
|
await openTab(tester, 'Explore');
|
||||||
|
await tester.tap(find.text('Japanese').first);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Add a copy anyway'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final all = await StudysetRepository(studysets).all();
|
||||||
|
expect(all, hasLength(2));
|
||||||
|
expect(
|
||||||
|
all.where((studyset) => studyset.content.publishedAt != null),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('deleted from the library, it can be added again', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
studysets.receiveRemoteChange(
|
||||||
|
'mine',
|
||||||
|
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||||
|
deletedAt: DateTime.utc(2026, 10, 15),
|
||||||
|
);
|
||||||
|
await showExplore(
|
||||||
|
tester,
|
||||||
|
FakeExploreGateway(
|
||||||
|
published: [published('Japanese', yours: 'mine')],
|
||||||
|
releases: [release('japanese', 'Japanese')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Yours'), findsNothing);
|
||||||
|
expect(find.text('Add'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a release in a schema this app does not know is not added, '
|
testWidgets('a release in a schema this app does not know is not added, '
|
||||||
'and says why', (tester) async {
|
'and says why', (tester) async {
|
||||||
final newer = release('japanese', 'Japanese');
|
final newer = release('japanese', 'Japanese');
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||||
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
|
||||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
|
||||||
/// How many studysets a page of Explore lists.
|
/// How many studysets a page of Explore lists.
|
||||||
const explorePageSize = 30;
|
const explorePageSize = 30;
|
||||||
|
|
@ -263,7 +264,10 @@ class ExploreEndpoint extends Endpoint {
|
||||||
final rows = await session.db.unsafeQuery(
|
final rows = await session.db.unsafeQuery(
|
||||||
'SELECT * FROM ('
|
'SELECT * FROM ('
|
||||||
'SELECT DISTINCT ON (r."publishedStudysetId") r.*, p."publicId", '
|
'SELECT DISTINCT ON (r."publishedStudysetId") r.*, p."publicId", '
|
||||||
'p."slug", p."learners", f."handle", f."displayName", f."avatarHash" '
|
'p."slug", p."learners", f."handle", f."displayName", f."avatarHash", '
|
||||||
|
// The author's own studyset, for the author alone.
|
||||||
|
'CASE WHEN p."authUserId"::text = @me THEN p."studysetId"::text '
|
||||||
|
'END AS "yourStudysetId" '
|
||||||
'FROM "studyset_release" r '
|
'FROM "studyset_release" r '
|
||||||
'JOIN "published_studyset" p ON p."id" = r."publishedStudysetId" '
|
'JOIN "published_studyset" p ON p."id" = r."publishedStudysetId" '
|
||||||
'JOIN "public_profile" f ON f."authUserId" = p."authUserId" '
|
'JOIN "public_profile" f ON f."authUserId" = p."authUserId" '
|
||||||
|
|
@ -273,7 +277,10 @@ class ExploreEndpoint extends Endpoint {
|
||||||
'${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')} '}'
|
'${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')} '}'
|
||||||
'ORDER BY latest."releasedAt" DESC, latest."id" DESC'
|
'ORDER BY latest."releasedAt" DESC, latest."id" DESC'
|
||||||
'${paged ? ' LIMIT @limit OFFSET @offset' : ''}',
|
'${paged ? ' LIMIT @limit OFFSET @offset' : ''}',
|
||||||
parameters: QueryParameters.named(parameters),
|
parameters: QueryParameters.named({
|
||||||
|
...parameters,
|
||||||
|
'me': session.authenticated!.authUserId.uuid,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
final domain = accountDomain(session.serverpod);
|
final domain = accountDomain(session.serverpod);
|
||||||
return [
|
return [
|
||||||
|
|
@ -310,6 +317,10 @@ class ExploreEndpoint extends Endpoint {
|
||||||
pathCount: latest['pathCount'] as int,
|
pathCount: latest['pathCount'] as int,
|
||||||
pdfCount: latest['pdfCount'] as int,
|
pdfCount: latest['pdfCount'] as int,
|
||||||
learners: learnersShown(latest['learners'] as int),
|
learners: learnersShown(latest['learners'] as int),
|
||||||
|
yourStudysetId: switch (latest['yourStudysetId']) {
|
||||||
|
final String id => UuidValue.withValidation(id),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,7 @@ fields:
|
||||||
### How many people learn it here, rounded down (5, 10, 20, 50, 100, …);
|
### How many people learn it here, rounded down (5, 10, 20, 50, 100, …);
|
||||||
### null for fewer than 5, so that nobody can be told apart.
|
### null for fewer than 5, so that nobody can be told apart.
|
||||||
learners: int?
|
learners: int?
|
||||||
|
### For its author asking, their studyset it was published from (decided
|
||||||
|
### 2026-10-16: Explore opens it rather than adding a copy of it); null for
|
||||||
|
### everyone else.
|
||||||
|
yourStudysetId: UuidValue?
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ abstract class ExploreEntry
|
||||||
required this.pathCount,
|
required this.pathCount,
|
||||||
required this.pdfCount,
|
required this.pdfCount,
|
||||||
this.learners,
|
this.learners,
|
||||||
|
this.yourStudysetId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ExploreEntry({
|
factory ExploreEntry({
|
||||||
|
|
@ -68,6 +69,7 @@ abstract class ExploreEntry
|
||||||
required int pathCount,
|
required int pathCount,
|
||||||
required int pdfCount,
|
required int pdfCount,
|
||||||
int? learners,
|
int? learners,
|
||||||
|
_is.UuidValue? yourStudysetId,
|
||||||
}) = _ExploreEntryImpl;
|
}) = _ExploreEntryImpl;
|
||||||
|
|
||||||
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
|
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
|
@ -100,6 +102,11 @@ abstract class ExploreEntry
|
||||||
pathCount: jsonSerialization['pathCount'] as int,
|
pathCount: jsonSerialization['pathCount'] as int,
|
||||||
pdfCount: jsonSerialization['pdfCount'] as int,
|
pdfCount: jsonSerialization['pdfCount'] as int,
|
||||||
learners: jsonSerialization['learners'] as int?,
|
learners: jsonSerialization['learners'] as int?,
|
||||||
|
yourStudysetId: jsonSerialization['yourStudysetId'] == null
|
||||||
|
? null
|
||||||
|
: _is.UuidValueJsonExtension.fromJson(
|
||||||
|
jsonSerialization['yourStudysetId'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,6 +170,11 @@ abstract class ExploreEntry
|
||||||
/// null for fewer than 5, so that nobody can be told apart.
|
/// null for fewer than 5, so that nobody can be told apart.
|
||||||
int? learners;
|
int? learners;
|
||||||
|
|
||||||
|
/// For its author asking, their studyset it was published from (decided
|
||||||
|
/// 2026-10-16: Explore opens it rather than adding a copy of it); null for
|
||||||
|
/// everyone else.
|
||||||
|
_is.UuidValue? yourStudysetId;
|
||||||
|
|
||||||
/// Returns a shallow copy of this [ExploreEntry]
|
/// Returns a shallow copy of this [ExploreEntry]
|
||||||
/// with some or all fields replaced by the given arguments.
|
/// with some or all fields replaced by the given arguments.
|
||||||
@_is.useResult
|
@_is.useResult
|
||||||
|
|
@ -191,6 +203,7 @@ abstract class ExploreEntry
|
||||||
int? pathCount,
|
int? pathCount,
|
||||||
int? pdfCount,
|
int? pdfCount,
|
||||||
int? learners,
|
int? learners,
|
||||||
|
_is.UuidValue? yourStudysetId,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
@ -220,6 +233,7 @@ abstract class ExploreEntry
|
||||||
'pathCount': pathCount,
|
'pathCount': pathCount,
|
||||||
'pdfCount': pdfCount,
|
'pdfCount': pdfCount,
|
||||||
if (learners != null) 'learners': learners,
|
if (learners != null) 'learners': learners,
|
||||||
|
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,6 +265,7 @@ abstract class ExploreEntry
|
||||||
'pathCount': pathCount,
|
'pathCount': pathCount,
|
||||||
'pdfCount': pdfCount,
|
'pdfCount': pdfCount,
|
||||||
if (learners != null) 'learners': learners,
|
if (learners != null) 'learners': learners,
|
||||||
|
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,6 +303,7 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
required int pathCount,
|
required int pathCount,
|
||||||
required int pdfCount,
|
required int pdfCount,
|
||||||
int? learners,
|
int? learners,
|
||||||
|
_is.UuidValue? yourStudysetId,
|
||||||
}) : super._(
|
}) : super._(
|
||||||
publicId: publicId,
|
publicId: publicId,
|
||||||
location: location,
|
location: location,
|
||||||
|
|
@ -313,6 +329,7 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
pathCount: pathCount,
|
pathCount: pathCount,
|
||||||
pdfCount: pdfCount,
|
pdfCount: pdfCount,
|
||||||
learners: learners,
|
learners: learners,
|
||||||
|
yourStudysetId: yourStudysetId,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns a shallow copy of this [ExploreEntry]
|
/// Returns a shallow copy of this [ExploreEntry]
|
||||||
|
|
@ -344,6 +361,7 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
int? pathCount,
|
int? pathCount,
|
||||||
int? pdfCount,
|
int? pdfCount,
|
||||||
Object? learners = _Undefined,
|
Object? learners = _Undefined,
|
||||||
|
Object? yourStudysetId = _Undefined,
|
||||||
}) {
|
}) {
|
||||||
return ExploreEntry(
|
return ExploreEntry(
|
||||||
publicId: publicId ?? this.publicId,
|
publicId: publicId ?? this.publicId,
|
||||||
|
|
@ -376,6 +394,9 @@ class _ExploreEntryImpl extends ExploreEntry {
|
||||||
pathCount: pathCount ?? this.pathCount,
|
pathCount: pathCount ?? this.pathCount,
|
||||||
pdfCount: pdfCount ?? this.pdfCount,
|
pdfCount: pdfCount ?? this.pdfCount,
|
||||||
learners: learners is int? ? learners : this.learners,
|
learners: learners is int? ? learners : this.learners,
|
||||||
|
yourStudysetId: yourStudysetId is _is.UuidValue?
|
||||||
|
? yourStudysetId
|
||||||
|
: this.yourStudysetId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,43 @@ void main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tells its author which of their studysets each of theirs was '
|
||||||
|
'published from, and nobody else', () async {
|
||||||
|
final japanese = await studyset(alice, 'Japanese');
|
||||||
|
final published = await publish(alice, japanese, 'japanese');
|
||||||
|
|
||||||
|
UuidValue? yoursIn(List<ExploreEntry> entries) => entries
|
||||||
|
.firstWhere((entry) => entry.publicId == published.publicId)
|
||||||
|
.yourStudysetId;
|
||||||
|
expect(
|
||||||
|
yoursIn(
|
||||||
|
await endpoints.explore.list(signedIn(alice), ExploreQuery()),
|
||||||
|
),
|
||||||
|
japanese,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
yoursIn(
|
||||||
|
await endpoints.explore.entries(signedIn(alice), [
|
||||||
|
published.publicId,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
japanese,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
yoursIn(await endpoints.explore.list(signedIn(bob), ExploreQuery())),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(yoursIn(await list()), isNull);
|
||||||
|
expect(
|
||||||
|
yoursIn(
|
||||||
|
await endpoints.explore.entries(signedIn(bob), [
|
||||||
|
published.publicId,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('says which of the studysets asked for are out, and their '
|
test('says which of the studysets asked for are out, and their '
|
||||||
'latest version', () async {
|
'latest version', () async {
|
||||||
final published = await publish(
|
final published = await publish(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue