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
3 changed files with 3386 additions and 22 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/feature/library/data/library_gateway.dart';
|
||||
|
||||
/// A library of made-up, neutral entries (ADR 0038, D16), with an access
|
||||
/// until [accessUntil]: a meditation in two lengths, a sound, a sleep story
|
||||
/// in an operator's own category, and a course of the first two.
|
||||
LibraryCatalogue sampleCatalogue({DateTime? accessUntil}) => LibraryCatalogue(
|
||||
items: [
|
||||
LibraryItem(
|
||||
id: 'evening-rest',
|
||||
title: 'Evening rest',
|
||||
category: 'meditation',
|
||||
description: 'A short sitting before sleep.',
|
||||
speaker: 'Sample Speaker',
|
||||
language: 'en',
|
||||
audio: [
|
||||
LibraryAudio(
|
||||
file: 'aaaaaaaaaaaaaaaaaaaaaaaa.mp3',
|
||||
label: 'Short',
|
||||
durationSeconds: 300,
|
||||
size: 1000,
|
||||
),
|
||||
LibraryAudio(
|
||||
file: 'bbbbbbbbbbbbbbbbbbbbbbbb.mp3',
|
||||
label: 'Long',
|
||||
durationSeconds: 1200,
|
||||
size: 4000,
|
||||
),
|
||||
],
|
||||
licence: 'CC BY 4.0',
|
||||
credit: 'Sample Speaker, for the example instance',
|
||||
),
|
||||
LibraryItem(
|
||||
id: 'soft-rain',
|
||||
title: 'Soft rain',
|
||||
category: 'sound',
|
||||
language: 'en',
|
||||
audio: [
|
||||
LibraryAudio(
|
||||
file: 'cccccccccccccccccccccccc.ogg',
|
||||
durationSeconds: 1800,
|
||||
size: 9000,
|
||||
),
|
||||
],
|
||||
licence: 'CC0 1.0',
|
||||
credit: 'Sample Recordist',
|
||||
),
|
||||
LibraryItem(
|
||||
id: 'box-breathing',
|
||||
title: 'Box breathing',
|
||||
category: 'breathwork',
|
||||
language: 'en',
|
||||
audio: [
|
||||
LibraryAudio(
|
||||
file: 'dddddddddddddddddddddddd.mp3',
|
||||
durationSeconds: 330,
|
||||
size: 2000,
|
||||
),
|
||||
],
|
||||
licence: 'CC BY 4.0',
|
||||
credit: 'Sample Speaker',
|
||||
),
|
||||
],
|
||||
courses: [
|
||||
LibraryCourse(
|
||||
id: 'calm-week',
|
||||
title: 'A calm week',
|
||||
description: 'Evenings to begin with.',
|
||||
category: 'meditation',
|
||||
language: 'en',
|
||||
lessons: [
|
||||
LibraryLesson(item: 'evening-rest', title: 'Evening one'),
|
||||
LibraryLesson(item: 'soft-rain'),
|
||||
],
|
||||
),
|
||||
],
|
||||
categories: [
|
||||
LibraryCategory(
|
||||
key: 'breathwork',
|
||||
english: 'Breathwork',
|
||||
german: 'Atemarbeit',
|
||||
),
|
||||
],
|
||||
filesUrl: 'https://meditate.example.org/library',
|
||||
access: 'signed-access',
|
||||
accessUntil: accessUntil ?? clock.now().add(const Duration(hours: 12)),
|
||||
);
|
||||
|
||||
/// [LibraryGateway] answering with [answer] (none: no library), or failing
|
||||
/// where [fails], counting the calls.
|
||||
class FakeLibraryGateway() implements LibraryGateway {
|
||||
LibraryCatalogue answer = LibraryCatalogue(
|
||||
items: [],
|
||||
courses: [],
|
||||
categories: [],
|
||||
);
|
||||
|
||||
/// Whether the instance can't be reached.
|
||||
bool fails = false;
|
||||
|
||||
/// How often the catalogue was asked for.
|
||||
int calls = 0;
|
||||
|
||||
@override
|
||||
Future<LibraryCatalogue> catalogue() async {
|
||||
calls++;
|
||||
if (fails) {
|
||||
throw StateError('offline');
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
}
|
||||
|
||||
/// [CatalogueStore] in memory.
|
||||
class MemoryCatalogueStore() implements CatalogueStore {
|
||||
LibraryCatalogue? kept;
|
||||
|
||||
@override
|
||||
Future<LibraryCatalogue?> read() async => kept;
|
||||
|
||||
@override
|
||||
Future<void> write(LibraryCatalogue catalogue) async => kept = catalogue;
|
||||
}
|
||||
|
||||
/// [LibraryDownloads] in memory, noting what was fetched from where.
|
||||
class MemoryLibraryDownloads() implements LibraryDownloads {
|
||||
final fetched = <String, Uri>{};
|
||||
|
||||
/// Whether fetching fails, as offline.
|
||||
bool fails = false;
|
||||
|
||||
@override
|
||||
Future<Set<String>> kept() async => fetched.keys.toSet();
|
||||
|
||||
@override
|
||||
Future<String?> pathOf(String served) async =>
|
||||
fetched.containsKey(served) ? '/device/library/$served' : null;
|
||||
|
||||
@override
|
||||
Future<void> keep(String served, Uri url) async {
|
||||
if (fails) {
|
||||
throw StateError('offline');
|
||||
}
|
||||
fetched[served] = url;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String served) async => fetched.remove(served);
|
||||
}
|
||||
|
||||
/// [GuidePlayer] noting what it was asked, playing nothing; a test moves it
|
||||
/// on and ends what plays.
|
||||
class RecordingGuidePlayer() implements GuidePlayer {
|
||||
/// Every call, in order: `play <source> loop`, `pause`, `resume`,
|
||||
/// `seek <ms>`, `stop`.
|
||||
final calls = <String>[];
|
||||
|
||||
/// Whether playing fails.
|
||||
bool fails = false;
|
||||
|
||||
final _positions = StreamController<Duration>.broadcast();
|
||||
final _finished = StreamController<void>.broadcast();
|
||||
|
||||
/// As the recording moved to [position].
|
||||
void moveTo(Duration position) => _positions.add(position);
|
||||
|
||||
/// As the recording played to its end.
|
||||
void finish() => _finished.add(null);
|
||||
|
||||
@override
|
||||
Stream<Duration> get positions => _positions.stream;
|
||||
|
||||
@override
|
||||
Stream<void> get finished => _finished.stream;
|
||||
|
||||
@override
|
||||
Future<void> play(GuideSource source, {bool loop = false}) async {
|
||||
if (fails) {
|
||||
throw StateError('no sound');
|
||||
}
|
||||
final from = switch (source) {
|
||||
GuideUrl(:final url) => '$url',
|
||||
GuideFile(:final path) => path,
|
||||
};
|
||||
calls.add('play $from${loop ? ' loop' : ''}');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async => calls.add('pause');
|
||||
|
||||
@override
|
||||
Future<void> resume() async => calls.add('resume');
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async =>
|
||||
calls.add('seek ${position.inMilliseconds}');
|
||||
|
||||
@override
|
||||
Future<void> stop() async => calls.add('stop');
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
Loading…
Reference in a new issue