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
33 changed files with 3386 additions and 22 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}'}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue