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:
parent
0b6f27d278
commit
811182b376
1 changed files with 3386 additions and 22 deletions
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue