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
33 changed files with 3386 additions and 22 deletions

View file

@ -171,5 +171,41 @@
"dreamStopDictating": "Diktat beenden",
"dreamListening": "Ich höre zu …",
"dreamSpeechUnavailable": "Dieses Gerät kann gerade nicht zuhören. Darf die App das Mikrofon nutzen?",
"todayDreamSpeak": "Erzählen"
"todayDreamSpeak": "Erzählen",
"exploreLibrary": "Von {server}",
"libraryCourses": "Kurse",
"libraryCourse": "Kurs",
"libraryCategoryMeditation": "Meditation",
"libraryCategorySleepStory": "Einschlafgeschichten",
"libraryCategorySound": "Klänge",
"libraryCategoryMovement": "Bewegung",
"libraryCategoryAutogenicTraining": "Autogenes Training",
"libraryCategoryProgressiveMuscleRelaxation": "Progressive Muskelentspannung",
"libraryCategoryRoutine": "Routinen",
"libraryCategoryDreamJourney": "Traumreisen",
"libraryCategoryKnowledge": "Wissen",
"libraryCategoryMorningStory": "Morgengeschichten",
"libraryCategoryOther": "Anderes",
"libraryLessons": "{count, plural, =1{1 Lektion} other{{count} Lektionen}}",
"libraryLessonsDone": "{done} von {count} erledigt",
"libraryLessonDone": "Erledigt",
"libraryWith": "Mit {speaker}",
"libraryPlay": "Abspielen",
"libraryPause": "Pause",
"libraryResume": "Weiter",
"libraryStop": "Beenden",
"libraryRecording": "Aufnahme",
"libraryKeep": "Auf dem Gerät behalten",
"libraryKeeping": "Wird geladen …",
"libraryKept": "Auf dem Gerät",
"libraryRemove": "Vom Gerät entfernen",
"libraryKeepFailed": "Das ließ sich nicht laden. Versuch es wieder, wenn du online bist.",
"libraryPlayFailed": "Das lässt sich gerade nicht abspielen. Bist du online?",
"librarySitWithSound": "Mit diesem Klang still sitzen",
"libraryCredit": "Nachweis",
"libraryLicence": "Lizenz",
"libraryCredits": "Nachweise der Bibliothek",
"libraryCreditsIntro": "Was die Bibliothek von {server} bietet, wem es jeweils zu verdanken ist und unter welcher Lizenz.",
"libraryGone": "Das ist nicht mehr in der Bibliothek.",
"libraryFromLibrary": "Aus der Bibliothek"
}

View file

@ -259,5 +259,79 @@
"dreamStopDictating": "Stop dictating",
"dreamListening": "Listening…",
"dreamSpeechUnavailable": "This device can't listen now. Does the app have the microphone's permission?",
"todayDreamSpeak": "Speak it"
"todayDreamSpeak": "Speak it",
"exploreLibrary": "From {server}",
"@exploreLibrary": {
"placeholders": {
"server": {
"type": "String"
}
}
},
"libraryCourses": "Courses",
"libraryCourse": "Course",
"libraryCategoryMeditation": "Meditation",
"libraryCategorySleepStory": "Sleep stories",
"libraryCategorySound": "Sounds",
"libraryCategoryMovement": "Movement",
"libraryCategoryAutogenicTraining": "Autogenic training",
"libraryCategoryProgressiveMuscleRelaxation": "Progressive muscle relaxation",
"libraryCategoryRoutine": "Routines",
"libraryCategoryDreamJourney": "Dream journeys",
"libraryCategoryKnowledge": "Knowledge",
"libraryCategoryMorningStory": "Morning stories",
"libraryCategoryOther": "Other",
"libraryLessons": "{count, plural, =1{1 lesson} other{{count} lessons}}",
"@libraryLessons": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLessonsDone": "{done} of {count} done",
"@libraryLessonsDone": {
"placeholders": {
"done": {
"type": "int"
},
"count": {
"type": "int"
}
}
},
"libraryLessonDone": "Done",
"libraryWith": "With {speaker}",
"@libraryWith": {
"placeholders": {
"speaker": {
"type": "String"
}
}
},
"libraryPlay": "Play",
"libraryPause": "Pause",
"libraryResume": "Resume",
"libraryStop": "Stop",
"libraryRecording": "Recording",
"libraryKeep": "Keep on this device",
"libraryKeeping": "Fetching…",
"libraryKept": "On this device",
"libraryRemove": "Remove from this device",
"libraryKeepFailed": "It couldn't be fetched. Try again when you're online.",
"libraryPlayFailed": "It can't be played now. Are you online?",
"librarySitWithSound": "Sit in silence with this sound",
"libraryCredit": "Credit",
"libraryLicence": "Licence",
"libraryCredits": "Library credits",
"libraryCreditsIntro": "What the library of {server} offers, whom each is credited to and under which licence.",
"@libraryCreditsIntro": {
"placeholders": {
"server": {
"type": "String"
}
}
},
"libraryGone": "This isn't in the library any more.",
"libraryFromLibrary": "From the library"
}

View file

@ -1131,6 +1131,222 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Speak it'**
String get todayDreamSpeak;
/// No description provided for @exploreLibrary.
///
/// In en, this message translates to:
/// **'From {server}'**
String exploreLibrary(String server);
/// No description provided for @libraryCourses.
///
/// In en, this message translates to:
/// **'Courses'**
String get libraryCourses;
/// No description provided for @libraryCourse.
///
/// In en, this message translates to:
/// **'Course'**
String get libraryCourse;
/// No description provided for @libraryCategoryMeditation.
///
/// In en, this message translates to:
/// **'Meditation'**
String get libraryCategoryMeditation;
/// No description provided for @libraryCategorySleepStory.
///
/// In en, this message translates to:
/// **'Sleep stories'**
String get libraryCategorySleepStory;
/// No description provided for @libraryCategorySound.
///
/// In en, this message translates to:
/// **'Sounds'**
String get libraryCategorySound;
/// No description provided for @libraryCategoryMovement.
///
/// In en, this message translates to:
/// **'Movement'**
String get libraryCategoryMovement;
/// No description provided for @libraryCategoryAutogenicTraining.
///
/// In en, this message translates to:
/// **'Autogenic training'**
String get libraryCategoryAutogenicTraining;
/// No description provided for @libraryCategoryProgressiveMuscleRelaxation.
///
/// In en, this message translates to:
/// **'Progressive muscle relaxation'**
String get libraryCategoryProgressiveMuscleRelaxation;
/// No description provided for @libraryCategoryRoutine.
///
/// In en, this message translates to:
/// **'Routines'**
String get libraryCategoryRoutine;
/// No description provided for @libraryCategoryDreamJourney.
///
/// In en, this message translates to:
/// **'Dream journeys'**
String get libraryCategoryDreamJourney;
/// No description provided for @libraryCategoryKnowledge.
///
/// In en, this message translates to:
/// **'Knowledge'**
String get libraryCategoryKnowledge;
/// No description provided for @libraryCategoryMorningStory.
///
/// In en, this message translates to:
/// **'Morning stories'**
String get libraryCategoryMorningStory;
/// No description provided for @libraryCategoryOther.
///
/// In en, this message translates to:
/// **'Other'**
String get libraryCategoryOther;
/// No description provided for @libraryLessons.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 lesson} other{{count} lessons}}'**
String libraryLessons(int count);
/// No description provided for @libraryLessonsDone.
///
/// In en, this message translates to:
/// **'{done} of {count} done'**
String libraryLessonsDone(int done, int count);
/// No description provided for @libraryLessonDone.
///
/// In en, this message translates to:
/// **'Done'**
String get libraryLessonDone;
/// No description provided for @libraryWith.
///
/// In en, this message translates to:
/// **'With {speaker}'**
String libraryWith(String speaker);
/// No description provided for @libraryPlay.
///
/// In en, this message translates to:
/// **'Play'**
String get libraryPlay;
/// No description provided for @libraryPause.
///
/// In en, this message translates to:
/// **'Pause'**
String get libraryPause;
/// No description provided for @libraryResume.
///
/// In en, this message translates to:
/// **'Resume'**
String get libraryResume;
/// No description provided for @libraryStop.
///
/// In en, this message translates to:
/// **'Stop'**
String get libraryStop;
/// No description provided for @libraryRecording.
///
/// In en, this message translates to:
/// **'Recording'**
String get libraryRecording;
/// No description provided for @libraryKeep.
///
/// In en, this message translates to:
/// **'Keep on this device'**
String get libraryKeep;
/// No description provided for @libraryKeeping.
///
/// In en, this message translates to:
/// **'Fetching…'**
String get libraryKeeping;
/// No description provided for @libraryKept.
///
/// In en, this message translates to:
/// **'On this device'**
String get libraryKept;
/// No description provided for @libraryRemove.
///
/// In en, this message translates to:
/// **'Remove from this device'**
String get libraryRemove;
/// No description provided for @libraryKeepFailed.
///
/// In en, this message translates to:
/// **'It couldn\'t be fetched. Try again when you\'re online.'**
String get libraryKeepFailed;
/// No description provided for @libraryPlayFailed.
///
/// In en, this message translates to:
/// **'It can\'t be played now. Are you online?'**
String get libraryPlayFailed;
/// No description provided for @librarySitWithSound.
///
/// In en, this message translates to:
/// **'Sit in silence with this sound'**
String get librarySitWithSound;
/// No description provided for @libraryCredit.
///
/// In en, this message translates to:
/// **'Credit'**
String get libraryCredit;
/// No description provided for @libraryLicence.
///
/// In en, this message translates to:
/// **'Licence'**
String get libraryLicence;
/// No description provided for @libraryCredits.
///
/// In en, this message translates to:
/// **'Library credits'**
String get libraryCredits;
/// No description provided for @libraryCreditsIntro.
///
/// In en, this message translates to:
/// **'What the library of {server} offers, whom each is credited to and under which licence.'**
String libraryCreditsIntro(String server);
/// No description provided for @libraryGone.
///
/// In en, this message translates to:
/// **'This isn\'t in the library any more.'**
String get libraryGone;
/// No description provided for @libraryFromLibrary.
///
/// In en, this message translates to:
/// **'From the library'**
String get libraryFromLibrary;
}
class _AppLocalizationsDelegate

View file

@ -607,4 +607,131 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get todayDreamSpeak => 'Erzählen';
@override
String exploreLibrary(String server) {
return 'Von $server';
}
@override
String get libraryCourses => 'Kurse';
@override
String get libraryCourse => 'Kurs';
@override
String get libraryCategoryMeditation => 'Meditation';
@override
String get libraryCategorySleepStory => 'Einschlafgeschichten';
@override
String get libraryCategorySound => 'Klänge';
@override
String get libraryCategoryMovement => 'Bewegung';
@override
String get libraryCategoryAutogenicTraining => 'Autogenes Training';
@override
String get libraryCategoryProgressiveMuscleRelaxation =>
'Progressive Muskelentspannung';
@override
String get libraryCategoryRoutine => 'Routinen';
@override
String get libraryCategoryDreamJourney => 'Traumreisen';
@override
String get libraryCategoryKnowledge => 'Wissen';
@override
String get libraryCategoryMorningStory => 'Morgengeschichten';
@override
String get libraryCategoryOther => 'Anderes';
@override
String libraryLessons(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Lektionen',
one: '1 Lektion',
);
return '$_temp0';
}
@override
String libraryLessonsDone(int done, int count) {
return '$done von $count erledigt';
}
@override
String get libraryLessonDone => 'Erledigt';
@override
String libraryWith(String speaker) {
return 'Mit $speaker';
}
@override
String get libraryPlay => 'Abspielen';
@override
String get libraryPause => 'Pause';
@override
String get libraryResume => 'Weiter';
@override
String get libraryStop => 'Beenden';
@override
String get libraryRecording => 'Aufnahme';
@override
String get libraryKeep => 'Auf dem Gerät behalten';
@override
String get libraryKeeping => 'Wird geladen …';
@override
String get libraryKept => 'Auf dem Gerät';
@override
String get libraryRemove => 'Vom Gerät entfernen';
@override
String get libraryKeepFailed =>
'Das ließ sich nicht laden. Versuch es wieder, wenn du online bist.';
@override
String get libraryPlayFailed =>
'Das lässt sich gerade nicht abspielen. Bist du online?';
@override
String get librarySitWithSound => 'Mit diesem Klang still sitzen';
@override
String get libraryCredit => 'Nachweis';
@override
String get libraryLicence => 'Lizenz';
@override
String get libraryCredits => 'Nachweise der Bibliothek';
@override
String libraryCreditsIntro(String server) {
return 'Was die Bibliothek von $server bietet, wem es jeweils zu verdanken ist und unter welcher Lizenz.';
}
@override
String get libraryGone => 'Das ist nicht mehr in der Bibliothek.';
@override
String get libraryFromLibrary => 'Aus der Bibliothek';
}

View file

@ -603,6 +603,132 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get todayDreamSpeak => 'Speak it';
@override
String exploreLibrary(String server) {
return 'From $server';
}
@override
String get libraryCourses => 'Courses';
@override
String get libraryCourse => 'Course';
@override
String get libraryCategoryMeditation => 'Meditation';
@override
String get libraryCategorySleepStory => 'Sleep stories';
@override
String get libraryCategorySound => 'Sounds';
@override
String get libraryCategoryMovement => 'Movement';
@override
String get libraryCategoryAutogenicTraining => 'Autogenic training';
@override
String get libraryCategoryProgressiveMuscleRelaxation =>
'Progressive muscle relaxation';
@override
String get libraryCategoryRoutine => 'Routines';
@override
String get libraryCategoryDreamJourney => 'Dream journeys';
@override
String get libraryCategoryKnowledge => 'Knowledge';
@override
String get libraryCategoryMorningStory => 'Morning stories';
@override
String get libraryCategoryOther => 'Other';
@override
String libraryLessons(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count lessons',
one: '1 lesson',
);
return '$_temp0';
}
@override
String libraryLessonsDone(int done, int count) {
return '$done of $count done';
}
@override
String get libraryLessonDone => 'Done';
@override
String libraryWith(String speaker) {
return 'With $speaker';
}
@override
String get libraryPlay => 'Play';
@override
String get libraryPause => 'Pause';
@override
String get libraryResume => 'Resume';
@override
String get libraryStop => 'Stop';
@override
String get libraryRecording => 'Recording';
@override
String get libraryKeep => 'Keep on this device';
@override
String get libraryKeeping => 'Fetching…';
@override
String get libraryKept => 'On this device';
@override
String get libraryRemove => 'Remove from this device';
@override
String get libraryKeepFailed =>
'It couldn\'t be fetched. Try again when you\'re online.';
@override
String get libraryPlayFailed => 'It can\'t be played now. Are you online?';
@override
String get librarySitWithSound => 'Sit in silence with this sound';
@override
String get libraryCredit => 'Credit';
@override
String get libraryLicence => 'Licence';
@override
String get libraryCredits => 'Library credits';
@override
String libraryCreditsIntro(String server) {
return 'What the library of $server offers, whom each is credited to and under which licence.';
}
@override
String get libraryGone => 'This isn\'t in the library any more.';
@override
String get libraryFromLibrary => 'From the library';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -6,6 +6,7 @@ import 'package:halkyon_meditate_flutter/src/app/meditate_frame.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_languages.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_router.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/bloc/journal_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/library_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/practice_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -105,6 +106,14 @@ class _MeditatePersonAppState() extends State<MeditatePersonApp> {
PersonScope.of<MeditatePerson>(context).dreams,
)..open(),
),
// The instance's library and the lessons done, for Explore, Today,
// Profile and Settings (D16).
BlocProvider(
create: (context) {
final person = PersonScope.of<MeditatePerson>(context);
return LibraryCubit(person.library, person.lessons)..open();
},
),
// Where sync stands: Settings' card and the dot on the cog.
BlocProvider(
create: (context) =>

View file

@ -10,8 +10,13 @@ import 'package:halkyon_meditate_flutter/src/core/device/screen_awake.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_meditate_flutter/src/core/sound/bell_player.dart';
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/dream_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/data/sync_dream_gateway.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/lesson_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/library_gateway.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/library_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/data/sync_lesson_gateway.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/data/sitting_repository.dart';
@ -46,6 +51,13 @@ class MeditatePerson(
/// The dreams they wrote down, sealed and synced (D14).
required final DreamRepository dreams,
/// The instance's library, and the recordings kept on the device (D16).
required final LibraryRepository library,
/// The lessons of the library's courses they did, sealed and synced
/// (D16).
required final LessonRepository lessons,
/// The device's bells; none in tests that don't say.
final BellPlayer bells = const NoBells(),
@ -56,6 +68,10 @@ class MeditatePerson(
/// The device's speech recognition, for dictating a dream (D15); none in
/// tests that don't say.
final SpeechInput speech = const NoSpeechInput(),
/// The device's player of the library's recordings (D16); none in tests
/// that don't say.
final GuidePlayer guide = const NoGuidePlayer(),
final Future<void> Function()? _onDispose,
}) implements Disposable {
/// Closes what was opened for the person.
@ -242,6 +258,7 @@ class MeditateDependencies({
bells: device.bells,
screen: device.screen,
speech: device.speech,
guide: device.guide,
onClosed: closed.complete,
);
} on Object {
@ -280,6 +297,7 @@ Future<MeditatePerson> _openPersonData(
required BellPlayer bells,
required ScreenAwake screen,
required SpeechInput speech,
required GuidePlayer guide,
required void Function() onClosed,
}) async {
final db = await client.createSyncSession(
@ -313,20 +331,35 @@ Future<MeditatePerson> _openPersonData(
final settings = SettingsRepository(SyncSettingsGateway(db, sync), keys);
final sittings = SittingRepository(SyncSittingGateway(db, sync), keys);
final dreams = DreamRepository(SyncDreamGateway(db, sync), keys);
final lessons = LessonRepository(SyncLessonGateway(db, sync), keys);
// The library's last catalogue and the recordings kept, in the person's
// folder, which goes with them (D16).
final library = LibraryRepository(
ServerpodLibraryGateway(client),
store: FileCatalogueStore(File(p.join(folder.path, 'library.json'))),
downloads: FileLibraryDownloads(
Directory(p.join(folder.path, 'library')),
),
);
return MeditatePerson(
person,
sync: sync,
settings: settings,
sittings: sittings,
dreams: dreams,
library: library,
lessons: lessons,
bells: bells,
screen: screen,
speech: speech,
guide: guide,
onDispose: () async {
try {
await settings.dispose();
await sittings.dispose();
await dreams.dispose();
await lessons.dispose();
await guide.stop();
await sync.dispose();
await db.close();
} finally {
@ -362,6 +395,9 @@ class MeditateDevice({
/// The device's speech recognition, for dictating a dream (D15).
final SpeechInput speech = const NoSpeechInput(),
/// The player of the library's recordings, one for the whole app (D16).
final GuidePlayer guide = const NoGuidePlayer(),
}) {
/// How long the recogniser may wait through a pause while a dream is
/// told, and how long one listen may last: as long as the platform allows
@ -381,6 +417,7 @@ class MeditateDevice({
appVersion: package.version,
bells: AudioplayersBellPlayer(),
screen: const WakelockScreenAwake(),
guide: AudioplayersGuidePlayer(),
// On the device where Android can (D15): a dream is the person's own.
speech: DeviceSpeechInput(
preferOnDevice: true,

View file

@ -10,6 +10,22 @@ abstract final class MeditatePaths() {
/// A practice's page, in Explore.
static String practice(String id) => '/explore/practices/$id';
/// A library item's page, in Explore (D16); as a lesson of [courseId]
/// where given.
static String libraryItem(String id, {String? courseId}) =>
'/explore/library/${Uri.encodeComponent(id)}'
'${courseId == null ? '' : '?course=${Uri.encodeQueryComponent(courseId)}'}';
/// A library course's page, in Explore (D16).
static String libraryCourse(String id) =>
'/explore/courses/${Uri.encodeComponent(id)}';
/// A library recording playing, over the tabs (D16).
static const listening = '/listening';
/// The library's credits and licences (D16).
static const libraryCredits = '/library-credits';
/// The Journal: the dreams the person wrote down (D14).
static const journal = '/journal';

View file

@ -1,6 +1,11 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_paths.dart';
import 'package:halkyon_meditate_flutter/src/feature/journal/widget/dream_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/listening_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/widget/course_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/widget/library_credits_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/widget/library_item_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/widget/listening_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/practice/widget/practice_screen.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/sitting_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/widget/sitting_screen.dart';
@ -12,9 +17,11 @@ import 'package:halkyon_meditate_flutter/src/tabs/profile_tab.dart';
import 'package:halkyon_meditate_flutter/src/tabs/today_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// The router of one person's app (ADR 0038, D1, D2, D14), as Learn's and
/// Train's: the four tabs in a shell, each with its own stack of screens,
/// and the sitting, a dream and Settings over them. Every route is built
/// The router of one person's app (ADR 0038, D1, D2, D14, D16), as Learn's
/// and Train's: the four tabs in a shell, each with its own stack of
/// screens (the library's items and courses in Explore's), and the sitting,
/// a recording playing, a dream, Settings and the library's credits over
/// them. Every route is built
/// inside the person's scope, so screens read their repositories from
/// `PersonScope`.
/// A sitting is handed its plan as it is (`extra`); without one (a link
@ -45,6 +52,20 @@ GoRouter buildMeditateRouter() => GoRouter(
builder: (context, state) =>
PracticeScreen(practiceId: state.pathParameters['id']!),
),
// The instance's library (D16): an item, as a lesson of the
// course the query names where it does, and a course.
GoRoute(
path: 'library/:id',
builder: (context, state) => LibraryItemScreen(
itemId: state.pathParameters['id']!,
courseId: state.uri.queryParameters['course'],
),
),
GoRoute(
path: 'courses/:id',
builder: (context, state) =>
CourseScreen(courseId: state.pathParameters['id']!),
),
],
),
],
@ -74,6 +95,20 @@ GoRouter buildMeditateRouter() => GoRouter(
builder: (context, state) =>
SittingScreen(plan: state.extra! as SittingPlan),
),
// A library recording playing (D16), handed its plan as the sitting
// is.
GoRoute(
path: MeditatePaths.listening,
redirect: (context, state) =>
state.extra is ListeningPlan ? null : MeditatePaths.explore,
builder: (context, state) =>
ListeningScreen(plan: state.extra! as ListeningPlan),
),
GoRoute(
path: MeditatePaths.libraryCredits,
builder: (context, state) =>
const FramedScreen(child: LibraryCreditsScreen()),
),
// A dream, new or kept (D14), in the frame without the rail, as
// Settings: writing on waking wants the whole screen.
GoRoute(

View file

@ -0,0 +1,106 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
/// Where a recording of the library plays from (ADR 0038, D16): the
/// instance, or the device where the person kept it.
sealed class const GuideSource();
/// A recording on the instance, fetched as it plays.
final class const GuideUrl(final Uri url) extends GuideSource;
/// A recording kept on the device.
final class const GuideFile(final String path) extends GuideSource;
/// Plays the library's recordings (ADR 0038, D16): a guided item, or a
/// sound looped under a silent sitting. One for the whole app, beside the
/// bells' own.
abstract interface class GuidePlayer() {
/// Where the playing has got to, as it moves.
Stream<Duration> get positions;
/// Fires when a recording played to its end (never for one looped).
Stream<void> get finished;
/// Plays [source] from its start, again and again where [loop]; throws
/// where it can't.
Future<void> play(GuideSource source, {bool loop = false});
Future<void> pause();
Future<void> resume();
Future<void> seek(Duration position);
/// Stops what plays.
Future<void> stop();
/// Lets go of the device's player.
Future<void> dispose();
}
/// [GuidePlayer] playing nothing: where the device plays no sound.
class const NoGuidePlayer() implements GuidePlayer {
@override
Stream<Duration> get positions => const Stream.empty();
@override
Stream<void> get finished => const Stream.empty();
@override
Future<void> play(GuideSource source, {bool loop = false}) async {}
@override
Future<void> pause() async {}
@override
Future<void> resume() async {}
@override
Future<void> seek(Duration position) async {}
@override
Future<void> stop() async {}
@override
Future<void> dispose() async {}
}
/// [GuidePlayer] on `audioplayers`, as the bells: the platform's player
/// (ExoPlayer, AVPlayer, GStreamer, Media Foundation), which fetches a
/// recording on the instance by ranges as it plays. Only while the app
/// runs in front: playing on with the screen off is for later (D16).
class AudioplayersGuidePlayer() implements GuidePlayer {
final _player = AudioPlayer();
@override
Stream<Duration> get positions => _player.onPositionChanged;
@override
Stream<void> get finished => _player.onPlayerComplete;
@override
Future<void> play(GuideSource source, {bool loop = false}) async {
await _player.stop();
await _player.setReleaseMode(loop ? ReleaseMode.loop : ReleaseMode.stop);
await _player.play(switch (source) {
GuideUrl(:final url) => UrlSource('$url'),
GuideFile(:final path) => DeviceFileSource(path),
});
}
@override
Future<void> pause() => _player.pause();
@override
Future<void> resume() => _player.resume();
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> stop() => _player.stop();
@override
Future<void> dispose() => _player.dispose();
}

View file

@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_meditate_client/halkyon_meditate_client.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';
/// The instance's library as every screen shows it (ADR 0038, D16): the
/// catalogue, the lessons the person did, the recordings kept on the
/// device and those being fetched.
final class const LibraryState({
final bool loaded = false,
final LibraryCatalogue? _catalogue,
final Set<DoneLesson> lessonsDone = const {},
/// The served names kept on the device.
final Set<String> kept = const {},
/// The served names being fetched to keep.
final Set<String> keeping = const {},
}) with Equatable {
/// The catalogue: an empty one until read.
LibraryCatalogue get catalogue => _catalogue ?? _empty;
static final _empty = LibraryCatalogue(
items: [],
courses: [],
categories: [],
);
/// How many of [course]'s lessons are done.
int doneOf(LibraryCourse course) => course.lessons
.where(
(lesson) =>
lessonsDone.contains((course: course.id, item: lesson.item)),
)
.length;
LibraryState copyWith({
bool? loaded,
LibraryCatalogue? catalogue,
Set<DoneLesson>? lessonsDone,
Set<String>? kept,
Set<String>? keeping,
}) => LibraryState(
loaded: loaded ?? this.loaded,
catalogue: catalogue ?? this.catalogue,
lessonsDone: lessonsDone ?? this.lessonsDone,
kept: kept ?? this.kept,
keeping: keeping ?? this.keeping,
);
@override
List<Object?> get props => [
loaded,
// The catalogue's content: Serverpod's models compare by identity.
catalogue.toJson().toString(),
lessonsDone,
kept,
keeping,
];
}
/// Holds the library for Explore, its screens, Today's favourites and
/// Settings' credits (ADR 0038, D16): the catalogue read as the person's app
/// opens (where the instance answers, else the copy kept), and the lessons
/// done read again whenever they change.
class LibraryCubit(
final LibraryRepository _library,
final LessonRepository _lessons,
) extends Cubit<LibraryState> {
this : super(const LibraryState());
StreamSubscription<void>? _changes;
late final _reads = CoalescingRunner(
_read,
onError: (error, stackTrace) =>
log('The library not read: $error', name: 'Library'),
);
/// Reads it, and follows the lessons done.
void open() {
_changes = _lessons.changes.listen((_) => unawaited(_readLessons()));
unawaited(_reads.run());
}
/// Reads the catalogue again.
Future<void> refresh() => _reads.run();
Future<void> _read() async {
final catalogue = await _library.read();
final kept = await _library.kept();
final done = await _lessonsDone();
if (!isClosed) {
emit(
state.copyWith(
loaded: true,
catalogue: catalogue,
kept: kept,
lessonsDone: done,
),
);
}
}
Future<Set<DoneLesson>> _lessonsDone() async {
try {
return await _lessons.done();
} on Object catch (error) {
log('The lessons done not read: $error', name: 'Library');
return const {};
}
}
Future<void> _readLessons() async {
final done = await _lessonsDone();
if (!isClosed) {
emit(state.copyWith(lessonsDone: done));
}
}
/// Keeps [lesson] as done now; throws `KeysMissing` where this device
/// can't seal.
Future<void> markDone(DoneLesson lesson) async {
await _lessons.markDone(lesson, clock.now());
await _readLessons();
}
/// Keeps the recording [served] on the device; throws where it can't be
/// fetched.
Future<void> keep(String served) async {
emit(state.copyWith(keeping: {...state.keeping, served}));
try {
await _library.keep(served);
} finally {
if (!isClosed) {
emit(
state.copyWith(
keeping: {...state.keeping}..remove(served),
kept: await _library.kept(),
),
);
}
}
}
/// Deletes the recording [served] from the device.
Future<void> delete(String served) async {
await _library.delete(served);
if (!isClosed) {
emit(state.copyWith(kept: await _library.kept()));
}
}
@override
Future<void> close() {
unawaited(_changes?.cancel());
return super.close();
}
}

View file

@ -0,0 +1,154 @@
import 'dart:async';
import 'dart:developer';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
show LibraryAudio, LibraryItem;
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/model/library.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/model/sitting.dart';
/// What to listen to (ADR 0038, D16): a library item's recording, as a
/// lesson of [courseId] where it was opened from one.
final class const ListeningPlan({
required final LibraryItem item,
required final LibraryAudio audio,
final String? courseId,
}) with Equatable {
/// How long it plays, as the manifest says.
Duration get length => Duration(seconds: audio.durationSeconds);
@override
List<Object?> get props => [item.id, audio.file, courseId];
}
/// Where the listening stands: [loading] until the recording plays,
/// [failed] where it can't.
enum ListeningPhase() {
loading,
playing,
paused,
done,
failed,
}
final class const ListeningState({
final ListeningPhase phase = ListeningPhase.loading,
final Duration position = Duration.zero,
}) with Equatable {
@override
List<Object?> get props => [phase, position];
}
/// A recording of the library playing (ADR 0038, D16): play and pause, and
/// seeking. Played to its end, it is kept as a sitting of the item
/// ([libraryPracticeId]) of its whole length, so the figures count it, and
/// as its course's lesson done where it was one; stopped before, nothing is
/// kept.
class ListeningCubit(
final ListeningPlan plan, {
required final GuidePlayer player,
/// Where the recording plays from; null where neither the device nor the
/// instance has it.
required final Future<GuideSource?> Function() source,
/// Keeps a sitting (the person's repository).
required final Future<void> Function(Sitting sitting) keep,
/// Keeps the course's lesson as done.
required final Future<void> Function(DoneLesson lesson) lessonDone,
}) extends Cubit<ListeningState> {
this : super(const ListeningState());
final _subscriptions = <StreamSubscription<void>>[];
DateTime? _startedAt;
/// Starts playing.
Future<void> start() async {
_subscriptions
..add(
player.positions.listen((position) {
if (state.phase == ListeningPhase.playing && !isClosed) {
emit(ListeningState(phase: state.phase, position: position));
}
}),
)
..add(player.finished.listen((_) => unawaited(_finish())));
try {
final from = await source();
if (from == null) {
throw StateError('No source for ${plan.audio.file}');
}
_startedAt = clock.now();
await player.play(from);
if (!isClosed && state.phase == ListeningPhase.loading) {
emit(const ListeningState(phase: ListeningPhase.playing));
}
} on Object catch (error) {
log('Not played: $error', name: 'Library');
if (!isClosed) {
emit(const ListeningState(phase: ListeningPhase.failed));
}
}
}
Future<void> pause() async {
if (state.phase == ListeningPhase.playing) {
emit(
ListeningState(phase: ListeningPhase.paused, position: state.position),
);
await player.pause();
}
}
Future<void> resume() async {
if (state.phase == ListeningPhase.paused) {
emit(
ListeningState(phase: ListeningPhase.playing, position: state.position),
);
await player.resume();
}
}
Future<void> seek(Duration position) async {
if (state.phase case ListeningPhase.playing || ListeningPhase.paused) {
emit(ListeningState(phase: state.phase, position: position));
await player.seek(position);
}
}
Future<void> _finish() async {
final startedAt = _startedAt;
if (isClosed || startedAt == null || state.phase == ListeningPhase.done) {
return;
}
emit(ListeningState(phase: ListeningPhase.done, position: plan.length));
final seconds = plan.audio.durationSeconds;
await keep(
Sitting(
practiceId: libraryPracticeId(plan.item.id),
startedAt: startedAt.toUtc(),
offsetMinutes: startedAt.timeZoneOffset.inMinutes,
plannedSeconds: seconds,
satSeconds: seconds,
),
);
if (plan.courseId case final course?) {
await lessonDone((course: course, item: plan.item.id));
}
}
@override
Future<void> close() {
for (final subscription in _subscriptions) {
unawaited(subscription.cancel());
}
if (state.phase != ListeningPhase.done) {
unawaited(player.stop());
}
return super.close();
}
}

View file

@ -0,0 +1,73 @@
import 'dart:typed_data';
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/sealed/sealed_row_gateway.dart';
import 'package:halkyon_meditate_flutter/src/core/sealed/sealed_rows.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/model/library.dart';
/// The schema of a lesson done's payload this app writes and reads.
const lessonSchemaVersion = 1;
/// A lesson done as its row's payload, before sealing.
ByteData encodeLesson(({DoneLesson lesson, DateTime at}) done) =>
encodeJson(lessonSchemaVersion, {
'course': done.lesson.course,
'item': done.lesson.item,
'doneAt': done.at.toUtc().toIso8601String(),
});
/// The lesson done [payload] holds, opened; null for a newer schema or one
/// it can't read.
({DoneLesson lesson, DateTime at})? decodeLesson(ByteData payload) =>
decodeJson(
payload,
version: lessonSchemaVersion,
name: 'Lesson',
read: (json) => (
lesson: (
course: json['course']! as String,
item: json['item']! as String,
),
at: DateTime.parse(json['doneAt']! as String),
),
);
/// The lessons of the library's courses the person did (ADR 0038, D16): a
/// row each, sealed with their key for its row ([SealedRows]), under an ID
/// keyed by the account, the course and the item, so a lesson done on two
/// devices is one row. They name the operator's ids: a lesson whose course
/// went from the library stays kept, and counts again should it return.
class LessonRepository(
final SealedRowGateway _gateway,
final MeditateKeys _keys,
) {
late final _rows = SealedRows<({DoneLesson lesson, DateTime at})>(
_gateway,
_keys,
table: 'lesson_done',
encode: encodeLesson,
decode: decodeLesson,
);
/// Fires when they changed, here or on another device.
Stream<void> get changes => _rows.changes;
/// Every lesson done that opens here.
Future<Set<DoneLesson>> done() async => {
for (final row in await _rows.all())
if (row.content case final done?) done.lesson,
};
/// Keeps [lesson] as done [at]; once. Throws [KeysMissing] where this
/// device can't seal.
Future<void> markDone(DoneLesson lesson, DateTime at) async {
final id = _keys.rowId('meditate/lesson/${lesson.course}/${lesson.item}');
if (!await _rows.has(id)) {
await _rows.insert((lesson: lesson, at: at), id: id);
}
}
/// Closes what it holds open.
Future<void> dispose() => _rows.dispose();
}

View file

@ -0,0 +1,124 @@
import 'dart:convert';
import 'dart:io';
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart';
import 'package:http/http.dart' as http;
/// Where the instance's library comes from (ADR 0038, D16): its server.
abstract interface class LibraryGateway() {
/// The catalogue, with an access to the files; throws where the instance
/// can't be reached.
Future<LibraryCatalogue> catalogue();
}
/// [LibraryGateway] on Meditate's server (`LibraryEndpoint`).
class const ServerpodLibraryGateway(final Client _client)
implements LibraryGateway {
@override
Future<LibraryCatalogue> catalogue() => _client.library.catalogue();
}
/// The last catalogue the device got, kept so that Explore and what the
/// person kept on the device still show offline.
abstract interface class CatalogueStore() {
Future<LibraryCatalogue?> read();
Future<void> write(LibraryCatalogue catalogue);
}
/// [CatalogueStore] in a file of the person's folder.
class const FileCatalogueStore(final File _file) implements CatalogueStore {
@override
Future<LibraryCatalogue?> read() async {
try {
if (!_file.existsSync()) {
return null;
}
return LibraryCatalogue.fromJson(
jsonDecode(await _file.readAsString()) as Map<String, dynamic>,
);
} on Object {
// A copy that doesn't read is no copy: the next catalogue replaces it.
return null;
}
}
@override
Future<void> write(LibraryCatalogue catalogue) async {
await _file.parent.create(recursive: true);
await _file.writeAsString(jsonEncode(catalogue.toJson()));
}
}
/// The recordings the person keeps on the device, by their served names
/// (ADR 0038, D16): fetched on their asking, deleted on their asking or with
/// their folder.
abstract interface class LibraryDownloads() {
/// The served names kept.
Future<Set<String>> kept();
/// Where [served] is kept on the device; null where it isn't.
Future<String?> pathOf(String served);
/// Fetches [url] and keeps it as [served]; throws where it can't.
Future<void> keep(String served, Uri url);
/// Deletes [served] from the device.
Future<void> delete(String served);
}
/// [LibraryDownloads] in a folder of the person's own.
class FileLibraryDownloads(
final Directory _folder, {
final http.Client? _httpClient,
}) implements LibraryDownloads {
late final _client = _httpClient ?? http.Client();
@override
Future<Set<String>> kept() async {
if (!_folder.existsSync()) {
return {};
}
return {
for (final entity in _folder.listSync())
if (entity is File && !entity.path.endsWith('.part'))
entity.uri.pathSegments.last,
};
}
@override
Future<String?> pathOf(String served) async {
final file = File('${_folder.path}/$served');
return file.existsSync() ? file.path : null;
}
@override
Future<void> keep(String served, Uri url) async {
await _folder.create(recursive: true);
final part = File('${_folder.path}/$served.part');
final response = await _client.send(http.Request('GET', url));
if (response.statusCode != HttpStatus.ok) {
await response.stream.drain<void>();
throw HttpException('${response.statusCode}', uri: url);
}
final sink = part.openWrite();
try {
await response.stream.pipe(sink);
} on Object {
if (part.existsSync()) {
await part.delete();
}
rethrow;
}
// Whole, or not at all.
await part.rename('${_folder.path}/$served');
}
@override
Future<void> delete(String served) async {
final file = File('${_folder.path}/$served');
if (file.existsSync()) {
await file.delete();
}
}
}

View file

@ -0,0 +1,89 @@
import 'dart:developer';
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';
/// The instance's library as the device has it (ADR 0038, D16): the
/// catalogue its server gives, the copy kept of the last one for offline,
/// and the recordings the person kept on the device. A recording plays from
/// the device where it is kept, else from the instance with the access the
/// catalogue brought, which is asked for again shortly before it ends.
class LibraryRepository(
final LibraryGateway _gateway, {
required final CatalogueStore store,
required final LibraryDownloads downloads,
}) {
/// How long before its access ends a catalogue is asked for again.
static const renewBefore = Duration(minutes: 30);
LibraryCatalogue? _catalogue;
/// The catalogue: the instance's where it answers, kept for offline;
/// else the copy kept, else an empty one.
Future<LibraryCatalogue> read() async {
try {
final catalogue = await _gateway.catalogue();
_catalogue = catalogue;
await store.write(catalogue);
return catalogue;
} on Object catch (error) {
log('The library not fetched: $error', name: 'Library');
return _catalogue ??= await store.read() ?? _empty;
}
}
static final _empty = LibraryCatalogue(
items: [],
courses: [],
categories: [],
);
/// Where the file [served] is on the instance, with an access that holds
/// a while yet; null where the instance gives none.
Future<Uri?> urlOf(String served) async {
var catalogue = _catalogue;
final until = catalogue?.accessUntil;
if (catalogue == null ||
until == null ||
until.difference(clock.now()) < renewBefore) {
catalogue = await read();
}
final (base, access) = (catalogue.filesUrl, catalogue.access);
if (base == null || access == null) {
return null;
}
return Uri.parse(
'$base/$served',
).replace(queryParameters: {'access': access});
}
/// Where the recording [served] plays from: the device where it is kept,
/// else the instance; null where neither has it.
Future<GuideSource?> sourceOf(String served) async {
if (await downloads.pathOf(served) case final path?) {
return GuideFile(path);
}
return switch (await urlOf(served)) {
final url? => GuideUrl(url),
null => null,
};
}
/// The recordings kept on the device.
Future<Set<String>> kept() => downloads.kept();
/// Keeps the recording [served] on the device; throws where it can't be
/// fetched.
Future<void> keep(String served) async {
final url = await urlOf(served);
if (url == null) {
throw StateError('The instance gives no access to $served');
}
await downloads.keep(served, url);
}
/// Deletes the recording [served] from the device.
Future<void> delete(String served) => downloads.delete(served);
}

View file

@ -0,0 +1,100 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
as protocol;
import 'package:halkyon_meditate_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// The lessons the person did on their local database ([SealedRowGateway]),
/// which the sync engine keeps in step with their other devices.
class SyncLessonGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements SealedRowGateway {
@override
Future<List<SealedRow>> rows() async => [
for (final row in await protocol.LessonDone.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<void> insert(SealedRow row) async {
await protocol.LessonDone.db.insertRow(
_db,
protocol.LessonDone(
id: protocol.UuidValue.withValidation(row.id),
keyId: row.envelope.keyId,
nonce: row.envelope.nonce,
payload: row.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.LessonDone.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.LessonDone.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(Iterable<String> ids) async {
final uuids = <protocol.UuidValue>{
for (final id in ids) protocol.UuidValue.withValidation(id),
};
if (uuids.isEmpty) {
return;
}
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
for (final row in await protocol.LessonDone.db.find(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
)) {
await protocol.LessonDone.db.updateRow(
_db,
protocol.LessonDone(
id: row.id,
spaceId: row.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.LessonDone.db.deleteWhere(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,109 @@
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
show LibraryAudio, LibraryCatalogue, LibraryCourse, LibraryItem;
import 'package:halkyon_meditate_flutter/src/feature/practice/model/practice.dart';
import 'package:halkyon_meditate_flutter/src/feature/settings/model/meditate_settings.dart';
/// What a library item is kept as in sittings and favourites, beside
/// Meditate's own practices (ADR 0038, D16): its id under `library/`, so
/// the operator's slugs never meet Meditate's.
String libraryPracticeId(String itemId) => 'library/$itemId';
/// The item's id a sitting or favourite [practiceId] names; null for one of
/// Meditate's own.
String? libraryItemIdOf(String? practiceId) =>
practiceId != null && practiceId.startsWith('library/')
? practiceId.substring('library/'.length)
: null;
/// The person's favourites of the library: the settings' list keeps them
/// beside Meditate's practices, under [libraryPracticeId].
extension LibraryFavourites on MeditateSettings {
/// The library items among the favourites, by their ids.
List<String> get libraryFavourites => [
for (final id in favourites) ?libraryItemIdOf(id),
];
}
/// The category of sounds, which may play looped under a silent sitting.
const soundCategory = 'sound';
/// A lesson done: the course's and the item's ids.
typedef DoneLesson = ({String course, String item});
extension LibraryCatalogueLookup on LibraryCatalogue {
/// Whether it offers nothing.
bool get isEmpty => items.isEmpty;
LibraryItem? itemById(String id) {
for (final item in items) {
if (item.id == id) {
return item;
}
}
return null;
}
LibraryCourse? courseById(String id) {
for (final course in courses) {
if (course.id == id) {
return course;
}
}
return null;
}
/// The categories its items and courses are in, in Meditate's order and
/// then the operator's.
List<String> get categoriesUsed {
final used = {
for (final item in items) item.category,
for (final course in courses) course.category,
};
return [
for (final key in libraryCategoryOrder)
if (used.contains(key)) key,
for (final category in categories)
if (used.contains(category.key)) category.key,
];
}
}
/// Meditate's own categories, by their keys in the manifest (the server's
/// `libraryCategories`), in the order chips show them.
const libraryCategoryOrder = [
'meditation',
'sleep-story',
'sound',
'movement',
'autogenic-training',
'progressive-muscle-relaxation',
'routine',
'dream-journey',
'knowledge',
'morning-story',
'other',
];
/// Whether [item] is what [query] looks for: over its title, description,
/// speaker and [categoryName], as Explore searches practices.
bool itemMatches(LibraryItem item, String query, String categoryName) =>
matchesSearch(
'${item.title} ${item.description ?? ''} ${item.speaker ?? ''} '
'$categoryName',
query,
);
/// Whether [course] is what [query] looks for, or one of its lessons'
/// items is.
bool courseMatches(
LibraryCourse course,
String query,
String categoryName,
) => matchesSearch(
'${course.title} ${course.description ?? ''} $categoryName',
query,
);
/// How long [audio] plays, in whole minutes (at least one).
int minutesOf(LibraryAudio audio) =>
((audio.durationSeconds + 30) ~/ 60).clamp(1, 1 << 20);

View file

@ -0,0 +1,90 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/library_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/model/library.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/widget/library_widgets.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// A library course's page in Explore (ADR 0038, D16): what it is, how many
/// of its lessons are done, and its lessons in order, each ticked once done
/// and opening its item as a lesson of the course.
class const CourseScreen({required final String courseId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<LibraryCubit>().state;
final course = state.catalogue.courseById(courseId);
return TopGradient(
background: theme.colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(course?.title ?? ''),
),
body: course == null
? Center(child: Text(l10n.libraryGone))
: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + FloatingNavigationBar.coverOf(context),
),
children: [
Text(
[
categoryName(
context,
state.catalogue,
course.category,
),
l10n.libraryLessonsDone(
state.doneOf(course),
course.lessons.length,
),
].join(' · '),
style: theme.textTheme.labelLarge,
),
if (course.description case final description?) ...[
const SizedBox(height: 12),
Text(description, style: theme.textTheme.bodyLarge),
],
const SizedBox(height: 16),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
for (final lesson in course.lessons)
if (state.catalogue.itemById(lesson.item)
case final item?)
LibraryItemTile(
item,
courseId: course.id,
title: lesson.title,
done: state.lessonsDone.contains((
course: course.id,
item: item.id,
)),
),
],
),
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,65 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/library_cubit.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// The library's credits and licences (ADR 0038, D16), from Settings beside
/// About: every item, whom it is credited to and under which licence, as
/// the operator's manifest says and the licences ask to show.
class const LibraryCreditsScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final items = context.watch<LibraryCubit>().state.catalogue.items;
return TopGradient(
background: theme.colorScheme.surfaceContainerLow,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(l10n.libraryCredits),
),
body: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
padding: const EdgeInsets.all(12),
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Text(
l10n.libraryCreditsIntro(AppScope.of(context).serverName),
),
),
if (items.isNotEmpty)
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
for (final item in items)
ListTile(
title: Text(item.title),
subtitle: Text(
'${l10n.libraryCredit}: ${item.credit}\n'
'${l10n.libraryLicence}: ${item.licence}'
'${item.licenceUrl == null ? '' : '\n${item.licenceUrl}'}',
),
),
],
),
),
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,264 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart';
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_paths.dart';
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/library_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/bloc/listening_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/model/library.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/widget/library_widgets.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/practice_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/sitting_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/sitting/widget/practice_widgets.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// A library item's page in Explore (ADR 0038, D16): what it is and who
/// speaks it, its recordings to choose from, its heart making it a
/// favourite, Play, keeping it on the device and removing it again, and for
/// a sound, sitting in silence with it looped; at its foot, its credit and
/// licence. One gone from the library says so.
class const LibraryItemScreen({
required final String itemId,
final String? courseId,
super.key,
}) extends StatefulWidget {
@override
State<LibraryItemScreen> createState() => _LibraryItemScreenState();
}
class _LibraryItemScreenState() extends State<LibraryItemScreen> {
int _recording = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final library = context.watch<LibraryCubit>().state;
final item = library.catalogue.itemById(widget.itemId);
final favouriteId = libraryPracticeId(widget.itemId);
final favourite = context.select<PracticeCubit, bool>(
(cubit) => cubit.state.settings.isFavourite(favouriteId),
);
final audio = item?.audio[_recording.clamp(0, item.audio.length - 1)];
return TopGradient(
background: theme.colorScheme.surface,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
title: Text(item?.title ?? ''),
actions: [
if (item != null)
IconButton(
tooltip: favourite
? l10n.practiceUnfavourite
: l10n.practiceFavourite,
isSelected: favourite,
icon: const Icon(LucideIcons.heart),
// Filled, which Lucide has no heart for.
selectedIcon: Icon(
Icons.favorite,
color: theme.colorScheme.primary,
),
onPressed: () => _toggleFavourite(context, favouriteId),
),
const SizedBox(width: 4),
],
),
body: item == null || audio == null
? Center(child: Text(l10n.libraryGone))
: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + FloatingNavigationBar.coverOf(context),
),
children: [
Row(
children: [
CategoryBadge(item.category, size: 56),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
categoryName(
context,
library.catalogue,
item.category,
),
style: theme.textTheme.labelLarge,
),
if (item.speaker case final speaker?)
Text(l10n.libraryWith(speaker)),
],
),
),
],
),
if (item.description case final description?) ...[
const SizedBox(height: 16),
Text(description, style: theme.textTheme.bodyLarge),
],
if (item.audio.length > 1) ...[
const SizedBox(height: 24),
Text(
l10n.libraryRecording,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (index, recording) in item.audio.indexed)
ChoiceChip(
label: Text(
recording.label ??
l10n.minutes(minutesOf(recording)),
),
selected: index == _recording,
onSelected: (_) =>
setState(() => _recording = index),
),
],
),
],
const SizedBox(height: 24),
FilledButton.icon(
icon: const Icon(LucideIcons.play),
label: Text(
'${l10n.libraryPlay} · '
'${l10n.minutes(minutesOf(audio))}',
),
onPressed: () => unawaited(
context.push(
MeditatePaths.listening,
extra: ListeningPlan(
item: item,
audio: audio,
courseId: widget.courseId,
),
),
),
),
if (item.category == soundCategory) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(LucideIcons.timer),
label: Text(l10n.librarySitWithSound),
onPressed: () => startSitting(
context,
SittingPlan(
minutes: context
.read<PracticeCubit>()
.state
.settings
.timerMinutes,
sound: (item: item.id, file: audio.file),
),
),
),
],
const SizedBox(height: 8),
_Keeping(audio),
const SizedBox(height: 24),
_Credit(item),
],
),
),
),
),
);
}
Future<void> _toggleFavourite(BuildContext context, String id) async {
final cubit = context.read<PracticeCubit>();
final messenger = ScaffoldMessenger.of(context);
final keysMissing = AppLocalizations.of(context).keysMissing;
try {
await cubit.writeSettings(cubit.state.settings.toggleFavourite(id));
} on KeysMissing {
messenger.showSnackBar(SnackBar(content: Text(keysMissing)));
}
}
}
/// Keeping [audio] on the device, or removing it again.
class const _Keeping(final LibraryAudio audio) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final state = context.watch<LibraryCubit>().state;
if (state.keeping.contains(audio.file)) {
return TextButton.icon(
icon: const SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
label: Text(l10n.libraryKeeping),
onPressed: null,
);
}
if (state.kept.contains(audio.file)) {
return TextButton.icon(
icon: const Icon(LucideIcons.trash2),
label: Text(l10n.libraryRemove),
onPressed: () =>
unawaited(context.read<LibraryCubit>().delete(audio.file)),
);
}
return TextButton.icon(
icon: const Icon(LucideIcons.download),
label: Text(l10n.libraryKeep),
onPressed: () => _keep(context),
);
}
Future<void> _keep(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final failed = AppLocalizations.of(context).libraryKeepFailed;
try {
await context.read<LibraryCubit>().keep(audio.file);
} on Object {
messenger.showSnackBar(SnackBar(content: Text(failed)));
}
}
}
/// Whom [item] is credited to, and under which licence.
class const _Credit(final LibraryItem item) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final style = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${l10n.libraryCredit}: ${item.credit}', style: style),
Text(
'${l10n.libraryLicence}: ${item.licence}'
'${item.licenceUrl == null ? '' : ' (${item.licenceUrl})'}',
style: style,
),
],
);
}
}

Some files were not shown because too many files have changed in this diff Show more