feat(meditate): the instance's library in the app: Explore, its player, courses, keeping offline

Explore shows the instance's library below Meditate's practices, where it
has one: its courses and items by category (Meditate's eleven, named in
English and German, and the operator's own) and by the same search; an
instance without one shows nothing more. An item's page: its recordings
to choose, Play, a favourite (kept in the sealed settings under
library/<id>), keeping it on the device and removing it, and for a sound,
a silent sitting with it looped; its credit and licence. The player
plays, pauses and seeks; played to its end it is kept as a sitting, and
as its course's lesson done (lesson_done, sealed and synced). Settings
lists the library's credits beside About; Today shows its favourites.

The last catalogue is kept in the person's folder for offline; files are
fetched with the catalogue's access, asked for again shortly before it
ends. ADR 0038, D16.

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-03 16:47:28 +01:00
commit 811182b376
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -7,6 +7,8 @@ import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/model/dream.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/lesson_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/library_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/data/sitting_repository.dart';
@ -14,8 +16,10 @@ import 'package:halkyon_meditate_flutter/src/feature/sitting/model/sitting.dart'
import 'package:halkyon_meditate_flutter/src/feature/sitting/widget/sitting_screen.dart';
import 'package:halkyon_meditate_flutter/src/tabs/journal_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../support/fake_library.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_sealed_row_gateway.dart';
import '../support/recording_device.dart';
@ -32,6 +36,10 @@ void main() {
late MemorySealedRowGateway sittingRows;
late MemorySealedRowGateway dreamRows;
late FakeSpeechInput speech;
late FakeLibraryGateway library;
late MemoryLibraryDownloads downloads;
late MemorySealedRowGateway lessonRows;
late RecordingGuidePlayer guide;
late RecordingBells bells;
late RecordingScreen screen;
@ -56,6 +64,10 @@ void main() {
sittingRows = MemorySealedRowGateway();
dreamRows = MemorySealedRowGateway();
speech = FakeSpeechInput();
library = FakeLibraryGateway();
downloads = MemoryLibraryDownloads();
lessonRows = MemorySealedRowGateway();
guide = RecordingGuidePlayer();
bells = RecordingBells();
screen = RecordingScreen();
store = MemoryInstanceStore();
@ -150,9 +162,16 @@ void main() {
settings: settings(),
sittings: sittings(),
dreams: dreams(),
library: LibraryRepository(
library,
store: MemoryCatalogueStore(),
downloads: downloads,
),
lessons: LessonRepository(lessonRows, keys),
bells: bells,
screen: screen,
speech: speech,
guide: guide,
);
},
),
@ -1141,4 +1160,215 @@ void main() {
findsOneWidget,
);
});
group("the instance's library (ADR 0038, D16)", () {
/// Opens Explore on an instance with the sample library.
Future<void> exploreLibrary(WidgetTester tester) async {
library.answer = sampleCatalogue();
await start(tester, usedBefore: true);
await goTab(tester, 'Explore');
}
/// Opens the library item [title] from Explore.
Future<void> openItem(WidgetTester tester, String title) async {
await exploreLibrary(tester);
await scrollTo(tester, find.text(title));
await tester.tap(find.text(title));
await tester.pumpAndSettle();
}
testWidgets('an instance without a library shows nothing of one: no '
'section in Explore, no credits in Settings', (tester) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Explore');
await scrollTo(tester, find.text('Kind wishes'));
expect(find.text('From meditate.example.org'), findsNothing);
await openSettings(tester);
expect(find.text('Library credits'), findsNothing);
});
testWidgets("Explore shows the instance's courses and items below the "
'practices, by category and by the search', (tester) async {
await exploreLibrary(tester);
await scrollTo(tester, find.text('From meditate.example.org'));
expect(find.text('A calm week'), findsOneWidget);
expect(find.text('Course · 2 lessons'), findsOneWidget);
await scrollTo(tester, find.text('Box breathing'));
expect(find.text('Soft rain'), findsOneWidget);
// The operator's own category, named as the manifest names it.
expect(find.text('Breathwork'), findsWidgets);
await scrollTo(tester, find.text('Sounds'), up: true);
await tester.tap(find.text('Sounds'));
await tester.pumpAndSettle();
await scrollTo(tester, find.text('Soft rain'));
expect(find.text('Evening rest'), findsNothing);
expect(find.text('A calm week'), findsNothing);
await scrollTo(tester, find.byType(TextField), up: true);
await tester.enterText(find.byType(TextField), 'evening');
await tester.pumpAndSettle();
await scrollTo(tester, find.text('All').last);
await tester.tap(find.text('All').last);
await tester.pumpAndSettle();
await scrollTo(tester, find.text('Evening rest'));
expect(find.text('Soft rain'), findsNothing);
});
testWidgets('an item plays its recording chosen; played to its end it '
'counts as a sitting', (tester) async {
await openItem(tester, 'Evening rest');
expect(find.text('With Sample Speaker'), findsOneWidget);
expect(
find.text('Credit: Sample Speaker, for the example instance'),
findsOneWidget,
);
expect(find.text('Licence: CC BY 4.0'), findsOneWidget);
await tester.tap(find.text('Long'));
await tester.pumpAndSettle();
await tester.tap(find.text('Play · 20 min'));
await tester.pumpAndSettle();
const long =
'https://meditate.example.org/library/'
'bbbbbbbbbbbbbbbbbbbbbbbb.mp3?access=signed-access';
expect(guide.calls, ['play $long']);
expect(find.text('Pause'), findsOneWidget);
await tester.tap(find.text('Pause'));
await tester.pumpAndSettle();
expect(guide.calls.last, 'pause');
await tester.tap(find.text('Resume'));
await tester.pumpAndSettle();
guide.finish();
await tester.pumpAndSettle();
expect(find.text('Well sat'), findsOneWidget);
final kept = await sittings().all();
expect(kept.single.sitting.practiceId, 'library/evening-rest');
expect(kept.single.sitting.satSeconds, 1200);
});
testWidgets('stopped before its end, nothing is kept', (tester) async {
await openItem(tester, 'Evening rest');
await tester.tap(find.text('Play · 5 min'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Stop'));
await tester.pumpAndSettle();
expect(guide.calls.last, 'stop');
expect(await sittings().all(), isEmpty);
});
testWidgets("a course's lessons, each ticked once played to its end, "
'its done ones counted', (tester) async {
await exploreLibrary(tester);
await scrollTo(tester, find.text('A calm week'));
await tester.tap(find.text('A calm week'));
await tester.pumpAndSettle();
expect(find.text('Meditation · 0 of 2 done'), findsOneWidget);
expect(find.text('Evening one'), findsOneWidget);
await tester.tap(find.text('Evening one'));
await tester.pumpAndSettle();
await tester.tap(find.text('Play · 5 min'));
await tester.pumpAndSettle();
guide.finish();
await tester.pumpAndSettle();
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Meditation · 1 of 2 done'), findsOneWidget);
expect(find.byIcon(LucideIcons.check), findsOneWidget);
expect(
await LessonRepository(lessonRows, keys).done(),
{(course: 'calm-week', item: 'evening-rest')},
);
});
testWidgets('a favourite of the library shows on Today', (tester) async {
await openItem(tester, 'Evening rest');
await tester.tap(find.byTooltip('Add to favourites'));
await tester.pumpAndSettle();
expect(
(await settings().read()).favourites,
['library/evening-rest'],
);
await goTab(tester, 'Today');
await scrollTo(tester, find.text('Evening rest'));
expect(find.text('Evening rest'), findsOneWidget);
});
testWidgets('kept on the device on asking, then played from there, and '
'removed again', (tester) async {
await openItem(tester, 'Soft rain');
await tester.tap(find.text('Keep on this device'));
await tester.pumpAndSettle();
expect(downloads.fetched.keys, ['cccccccccccccccccccccccc.ogg']);
expect(find.text('Remove from this device'), findsOneWidget);
await tester.tap(find.text('Play · 30 min'));
await tester.pumpAndSettle();
expect(
guide.calls.single,
'play /device/library/cccccccccccccccccccccccc.ogg',
);
await tester.tap(find.byTooltip('Stop'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove from this device'));
await tester.pumpAndSettle();
expect(downloads.fetched, isEmpty);
expect(find.text('Keep on this device'), findsOneWidget);
});
testWidgets("one that can't be fetched says so", (tester) async {
await openItem(tester, 'Soft rain');
downloads.fails = true;
await tester.tap(find.text('Keep on this device'));
await tester.pumpAndSettle();
expect(
find.text("It couldn't be fetched. Try again when you're online."),
findsOneWidget,
);
expect(find.text('Keep on this device'), findsOneWidget);
});
testWidgets('a sound plays looped under a silent sitting', (tester) async {
await openItem(tester, 'Soft rain');
expect(find.text('Sit in silence with this sound'), findsOneWidget);
await tester.tap(find.text('Sit in silence with this sound'));
await sit(tester, const Duration(seconds: 2));
expect(find.byType(SittingScreen), findsOneWidget);
const rain =
'https://meditate.example.org/library/'
'cccccccccccccccccccccccc.ogg?access=signed-access';
expect(guide.calls, ['play $rain loop']);
});
testWidgets("Settings lists the library's credits and licences", (
tester,
) async {
library.answer = sampleCatalogue();
await start(tester, usedBefore: true);
await openSettings(tester);
await tapInSettings(tester, 'Library credits');
expect(find.textContaining('Credit: Sample Recordist'), findsOneWidget);
expect(find.textContaining('Licence: CC0 1.0'), findsOneWidget);
});
});
}