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

@ -0,0 +1,296 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/listening_cubit.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/library/model/library.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/sitting_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/model/sitting.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';
void main() {
late FakeLibraryGateway gateway;
late MemoryCatalogueStore store;
late MemoryLibraryDownloads downloads;
late LibraryRepository library;
setUp(() {
gateway = FakeLibraryGateway()..answer = sampleCatalogue();
store = MemoryCatalogueStore();
downloads = MemoryLibraryDownloads();
library = LibraryRepository(gateway, store: store, downloads: downloads);
});
group("the instance's library on the device (ADR 0038, D16)", () {
test('reads the instance’s catalogue and keeps a copy; offline, the '
'copy; with neither, none', () async {
expect((await library.read()).items, hasLength(3));
expect(store.kept?.items, hasLength(3));
gateway.fails = true;
final offline = LibraryRepository(
gateway,
store: store,
downloads: downloads,
);
expect((await offline.read()).items, hasLength(3));
final fresh = LibraryRepository(
gateway,
store: MemoryCatalogueStore(),
downloads: downloads,
);
expect((await fresh.read()).items, isEmpty);
});
test('a file is fetched with the access the catalogue brought, asked for '
'again shortly before it ends', () async {
await library.read();
expect(
'${await library.urlOf('aaaaaaaaaaaaaaaaaaaaaaaa.mp3')}',
'https://meditate.example.org/library/'
'aaaaaaaaaaaaaaaaaaaaaaaa.mp3?access=signed-access',
);
expect(gateway.calls, 1);
gateway.answer = sampleCatalogue(
accessUntil: clock.now().add(const Duration(minutes: 10)),
);
await library.read();
await library.urlOf('aaaaaaaaaaaaaaaaaaaaaaaa.mp3');
expect(gateway.calls, 3, reason: 'ten minutes left: asked again');
});
test('plays from the device what the person kept there, else from the '
'instance, and keeps and removes on asking', () async {
await library.read();
const served = 'aaaaaaaaaaaaaaaaaaaaaaaa.mp3';
expect(await library.sourceOf(served), isA<GuideUrl>());
await library.keep(served);
expect(
downloads.fetched[served]?.queryParameters['access'],
'signed-access',
);
expect(await library.kept(), {served});
expect(
await library.sourceOf(served),
isA<GuideFile>().having((file) => file.path, 'path', endsWith(served)),
);
await library.delete(served);
expect(await library.kept(), isEmpty);
});
test('an instance without a library gives no file at all', () async {
gateway.answer = sampleCatalogue()
..filesUrl = null
..access = null;
await library.read();
expect(await library.sourceOf('aaaaaaaaaaaaaaaaaaaaaaaa.mp3'), isNull);
await expectLater(
library.keep('aaaaaaaaaaaaaaaaaaaaaaaa.mp3'),
throwsStateError,
);
});
test('favourites and sittings of the library are kept under library/, '
'apart from Meditate’s own practices', () {
expect(libraryPracticeId('evening-rest'), 'library/evening-rest');
expect(libraryItemIdOf('library/evening-rest'), 'evening-rest');
expect(libraryItemIdOf('breath'), isNull);
expect(libraryItemIdOf(null), isNull);
const settings = MeditateSettings(
favourites: ['breath', 'library/evening-rest'],
);
expect(settings.libraryFavourites, ['evening-rest']);
});
test('categories used come in Meditate’s order, then the operator’s', () {
expect(sampleCatalogue().categoriesUsed, [
'meditation',
'sound',
'breathwork',
]);
});
});
group('lessons done (ADR 0038, D16)', () {
late MeditateKeys keys;
late MemorySealedRowGateway rows;
setUp(() async {
keys = MeditateKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
await keys.refresh();
rows = MemorySealedRowGateway();
});
tearDown(() => keys.dispose());
test('are sealed, one row a lesson however often and on however many '
'devices it is done', () async {
final here = LessonRepository(rows, keys);
final there = LessonRepository(rows, keys);
final at = DateTime.utc(2026, 11, 3, 20);
await here.markDone((course: 'calm-week', item: 'evening-rest'), at);
await there.markDone((course: 'calm-week', item: 'evening-rest'), at);
await there.markDone((course: 'calm-week', item: 'soft-rain'), at);
expect(rows.byId, hasLength(2));
expect(await here.done(), {
(course: 'calm-week', item: 'evening-rest'),
(course: 'calm-week', item: 'soft-rain'),
});
for (final envelope in rows.byId.values) {
expect(envelope.keyId, isNotNull, reason: 'sealed');
}
});
test('a payload reads back as written, and one of a newer schema not '
'at all', () {
final done = (
lesson: (course: 'calm-week', item: 'evening-rest'),
at: DateTime.utc(2026, 11, 3, 20),
);
expect(decodeLesson(encodeLesson(done)), done);
expect(
decodeLesson(
encodeJson(lessonSchemaVersion + 1, {
'course': 'calm-week',
'item': 'evening-rest',
'doneAt': '2026-11-03T20:00:00.000Z',
}),
),
isNull,
);
});
});
group('listening (ADR 0038, D16)', () {
late RecordingGuidePlayer player;
late List<Sitting> kept;
late List<DoneLesson> lessons;
setUp(() {
player = RecordingGuidePlayer();
kept = [];
lessons = [];
});
ListeningCubit listen({String? course, GuideSource? source}) {
final catalogue = sampleCatalogue();
final item = catalogue.items.first;
return ListeningCubit(
ListeningPlan(item: item, audio: item.audio.first, courseId: course),
player: player,
source: () async =>
source ?? GuideUrl(Uri.parse('https://example.org/a.mp3')),
keep: (sitting) async => kept.add(sitting),
lessonDone: (lesson) async => lessons.add(lesson),
);
}
test('played to its end, it is kept as a sitting of its whole length, '
'and as its course’s lesson done', () async {
final cubit = listen(course: 'calm-week');
await cubit.start();
expect(cubit.state.phase, ListeningPhase.playing);
expect(player.calls, ['play https://example.org/a.mp3']);
player.finish();
await pumpEventQueue();
expect(cubit.state.phase, ListeningPhase.done);
expect(kept.single.practiceId, 'library/evening-rest');
expect(kept.single.plannedSeconds, 300);
expect(kept.single.satSeconds, 300);
expect(lessons, [(course: 'calm-week', item: 'evening-rest')]);
await cubit.close();
expect(player.calls, isNot(contains('stop')));
});
test(
'stopped before its end, nothing is kept and the player stops',
() async {
final cubit = listen();
await cubit.start();
await cubit.pause();
expect(cubit.state.phase, ListeningPhase.paused);
await cubit.resume();
await cubit.seek(const Duration(seconds: 30));
await cubit.close();
expect(player.calls, [
'play https://example.org/a.mp3',
'pause',
'resume',
'seek 30000',
'stop',
]);
expect(kept, isEmpty);
expect(lessons, isEmpty);
},
);
test('one that can’t play says so', () async {
player.fails = true;
final cubit = listen();
await cubit.start();
expect(cubit.state.phase, ListeningPhase.failed);
await cubit.close();
});
});
group('a sound under a silent sitting (ADR 0038, D16)', () {
test(
'loops while the sitting runs, pauses with it and stops at its end',
() async {
final player = RecordingGuidePlayer();
final cubit = SittingCubit(
const SittingPlan(
minutes: 10,
sound: (item: 'soft-rain', file: 'c.ogg'),
),
settings: const MeditateSettings(
startBell: false,
keepScreenOn: false,
),
bells: RecordingBells(),
screen: RecordingScreen(),
keep: (sitting) async {},
sound: player,
soundSource: () async => const GuideFile('/device/c.ogg'),
)..start();
await pumpEventQueue();
expect(player.calls, ['play /device/c.ogg loop']);
cubit
..pause()
..resume();
await cubit.end(keep: false);
expect(player.calls, [
'play /device/c.ogg loop',
'pause',
'resume',
'stop',
]);
await cubit.close();
},
);
});
}