feat(explore): cards as the library's, in its colour, with a description of their own

Explore lists its studysets in a grid as the library does: each card in
the studyset's colour along the top, its title under its secondary
title, the start of its description and what it holds, then, quieter,
its author's handle, the release and the licence, and a compact Add.
A tap opens the summary, which shows the colour too.

The Publish sheet has a Description on Explore, starting from the
latest release's, else the studyset's own, and sent with the release:
a studyset may introduce itself to strangers while its own description
stays empty in the library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 17:47:29 +02:00
commit 21b71a4413
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 397 additions and 102 deletions

View file

@ -120,12 +120,11 @@ void main() {
);
expect(find.text('Japanese'), findsOneWidget);
expect(find.text('by Alice · @alice'), findsOneWidget);
// Its author, release and licence quieter, below what it holds.
expect(find.text('Kana, kanji and grammar from N5 on.'), findsOneWidget);
expect(find.textContaining('v3 · '), findsOneWidget);
expect(find.text('812 flashcards'), findsOneWidget);
expect(find.text('CC-BY-SA-4.0'), findsOneWidget);
expect(find.text('Add to library'), findsOneWidget);
expect(find.text('@alice · v3 · CC BY-SA 4.0'), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
});
testWidgets('says how many learn it here, from 5 on, and what a copy is '
@ -268,7 +267,7 @@ void main() {
expect(copies.single.content.title, 'Japanese');
expect(copies.single.content.publishedAt, 'halkyon.eu/@alice/japanese');
expect(find.text('Added to your library.'), findsOneWidget);
expect(find.text('In your library'), findsOneWidget);
expect(find.text('In library'), findsOneWidget);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
@ -298,8 +297,8 @@ void main() {
FakeExploreGateway(published: [published('Japanese')]),
);
expect(find.text('In your library'), findsOneWidget);
expect(find.text('Add to library'), findsNothing);
expect(find.text('In library'), findsOneWidget);
expect(find.text('Add'), findsNothing);
});
testWidgets('a release in a schema this app does not know is not added, '
@ -325,7 +324,7 @@ void main() {
),
);
await tester.tap(find.text('Add to library'));
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(

View file

@ -38,18 +38,25 @@ void main() {
});
/// A studyset on this device before the app starts.
void stored(String id, String title, {String? license, String? parent}) =>
studysets.receiveRemoteChange(
id,
encodeStudysetPayload(
StudysetContent(
title: title,
description: RichContent.empty(),
license: license,
),
),
parentId: parent,
);
void stored(
String id,
String title, {
String? license,
String? parent,
String description = '',
}) => studysets.receiveRemoteChange(
id,
encodeStudysetPayload(
StudysetContent(
title: title,
description: description.isEmpty
? RichContent.empty()
: RichContent.plain(description),
license: license,
),
),
parentId: parent,
);
/// Opens the screen of the studyset [id] as a person with an identity
/// ([identified]), whose profile is [profile], in a window [width] wide.
@ -123,6 +130,26 @@ void main() {
expect(find.text('Publish v2'), findsOneWidget);
});
testWidgets("Explore's description starts as the studyset's own and goes "
'with the release; the next publish starts from it', (tester) async {
stored(
'japanese',
'Japanese',
license: 'CC-BY-SA-4.0',
description: 'From N5 on.',
);
await showStudyset(tester, 'japanese');
await openSheet(tester);
expect(find.text('Description on Explore'), findsOneWidget);
expect(find.text('From N5 on.'), findsWidgets);
await tester.tap(find.text('Publish v1'));
await tester.pumpAndSettle();
expect(publishing.descriptions.single?.plainText, 'From N5 on.');
expect(find.text('Publish v2'), findsOneWidget);
});
testWidgets('a published studyset shows a globe, its releases and where '
'it is', (tester) async {
stored('japanese', 'Japanese', license: 'CC-BY-NC-ND-4.0');
@ -322,12 +349,13 @@ void main() {
),
findsOneWidget,
);
await tester.tap(
find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('猫'),
),
final unsettled = find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('猫'),
);
await tester.ensureVisible(unsettled);
await tester.pumpAndSettle();
await tester.tap(unsettled);
await tester.pumpAndSettle();
expect(find.byType(SheetScaffold), findsNothing);

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
@ -16,6 +17,9 @@ class FakePublishGateway({
/// What each publish was asked: the studyset, name and notes.
final publishes = <({String studysetId, String slug, String notes})>[];
/// The description for Explore each publish sent, in order.
final descriptions = <RichContent?>[];
/// When set, every request fails with it.
Exception? fail;
@ -58,11 +62,13 @@ class FakePublishGateway({
String studysetId, {
required String slug,
required String notes,
RichContent? description,
}) async {
if (fail case final error?) {
throw error;
}
publishes.add((studysetId: studysetId, slug: slug, notes: notes));
descriptions.add(description);
if (refusals.isNotEmpty) {
throw refusals.removeAt(0);
}
@ -81,6 +87,7 @@ class FakePublishGateway({
releasedAt: DateTime.utc(2026, 10, 9),
license: 'CC-BY-SA-4.0',
notes: notes,
description: description,
),
...?before?.releases,
],