feat(meditate): the library says what a recording speaks, and shows the languages chosen (ADR 0038, D17)
An item's page names its spoken language. Where the library has several, its cards do too and Explore offers them, several at once, none meaning every one, kept in the person's sealed settings. A library with one language asks nothing. Whats-New: The library says each recording's spoken language, and Explore lets you choose among several. Whats-New-De: Die Bibliothek nennt die gesprochene Sprache jeder Aufnahme, und bei mehreren wählst du sie in „Entdecken“ aus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1fa5890f8e
commit
3e69e6b2f7
14 changed files with 287 additions and 4 deletions
|
|
@ -207,5 +207,9 @@
|
|||
"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"
|
||||
"libraryFromLibrary": "Aus der Bibliothek",
|
||||
"exploreSpokenLanguage": "Gesprochene Sprache",
|
||||
"languageEnglish": "Englisch",
|
||||
"languageGerman": "Deutsch",
|
||||
"librarySpokenIn": "Gesprochen auf {language}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,5 +333,16 @@
|
|||
}
|
||||
},
|
||||
"libraryGone": "This isn't in the library any more.",
|
||||
"libraryFromLibrary": "From the library"
|
||||
"libraryFromLibrary": "From the library",
|
||||
"exploreSpokenLanguage": "Spoken language",
|
||||
"languageEnglish": "English",
|
||||
"languageGerman": "German",
|
||||
"librarySpokenIn": "Spoken in {language}",
|
||||
"@librarySpokenIn": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1347,6 +1347,30 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'From the library'**
|
||||
String get libraryFromLibrary;
|
||||
|
||||
/// No description provided for @exploreSpokenLanguage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spoken language'**
|
||||
String get exploreSpokenLanguage;
|
||||
|
||||
/// No description provided for @languageEnglish.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'English'**
|
||||
String get languageEnglish;
|
||||
|
||||
/// No description provided for @languageGerman.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'German'**
|
||||
String get languageGerman;
|
||||
|
||||
/// No description provided for @librarySpokenIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spoken in {language}'**
|
||||
String librarySpokenIn(String language);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -734,4 +734,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get libraryFromLibrary => 'Aus der Bibliothek';
|
||||
|
||||
@override
|
||||
String get exploreSpokenLanguage => 'Gesprochene Sprache';
|
||||
|
||||
@override
|
||||
String get languageEnglish => 'Englisch';
|
||||
|
||||
@override
|
||||
String get languageGerman => 'Deutsch';
|
||||
|
||||
@override
|
||||
String librarySpokenIn(String language) {
|
||||
return 'Gesprochen auf $language';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -729,6 +729,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get libraryFromLibrary => 'From the library';
|
||||
|
||||
@override
|
||||
String get exploreSpokenLanguage => 'Spoken language';
|
||||
|
||||
@override
|
||||
String get languageEnglish => 'English';
|
||||
|
||||
@override
|
||||
String get languageGerman => 'German';
|
||||
|
||||
@override
|
||||
String librarySpokenIn(String language) {
|
||||
return 'Spoken in $language';
|
||||
}
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ extension LibraryFavourites on MeditateSettings {
|
|||
];
|
||||
}
|
||||
|
||||
/// The primary language of a [tag] (`de-CH`: `de`), by which the library
|
||||
/// groups what is spoken.
|
||||
String languageKey(String tag) => tag.split('-').first.toLowerCase();
|
||||
|
||||
/// The category of sounds, which may play looped under a silent sitting.
|
||||
const soundCategory = 'sound';
|
||||
|
||||
|
|
@ -52,6 +56,24 @@ extension LibraryCatalogueLookup on LibraryCatalogue {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// The spoken languages its items and courses are in, by [languageKey].
|
||||
List<String> get languagesUsed => {
|
||||
for (final item in items) languageKey(item.language),
|
||||
for (final course in courses) languageKey(course.language),
|
||||
}.toList()..sort();
|
||||
|
||||
/// Of [chosen], the languages to show: none, meaning every one, where there
|
||||
/// is only one to choose or none chosen is offered any more.
|
||||
List<String> languagesShown(List<String> chosen) {
|
||||
final used = languagesUsed;
|
||||
return used.length < 2
|
||||
? const []
|
||||
: [
|
||||
for (final language in chosen)
|
||||
if (used.contains(language)) language,
|
||||
];
|
||||
}
|
||||
|
||||
/// The categories its items and courses are in, in Meditate's order and
|
||||
/// then the operator's.
|
||||
List<String> get categoriesUsed {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ class _LibraryItemScreenState() extends State<LibraryItemScreen> {
|
|||
),
|
||||
if (item.speaker case final speaker?)
|
||||
Text(l10n.libraryWith(speaker)),
|
||||
Text(
|
||||
l10n.librarySpokenIn(
|
||||
languageName(l10n, item.language),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -69,10 +69,20 @@ String itemLine(
|
|||
return [
|
||||
categoryName(context, catalogue, item.category),
|
||||
?item.speaker,
|
||||
if (catalogue.languagesUsed.length > 1) languageName(l10n, item.language),
|
||||
item.audio.map((audio) => l10n.minutes(minutesOf(audio))).join(', '),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
/// The spoken language [tag] in the app's language; the tag itself for one
|
||||
/// the app has no name for.
|
||||
String languageName(AppLocalizations l10n, String tag) =>
|
||||
switch (languageKey(tag)) {
|
||||
'en' => l10n.languageEnglish,
|
||||
'de' => l10n.languageGerman,
|
||||
_ => tag,
|
||||
};
|
||||
|
||||
/// [category]'s badge, as a practice's kind's.
|
||||
class const CategoryBadge(
|
||||
final String category, {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ ByteData encodeSettings(MeditateSettings settings) => encodeJson(
|
|||
'showStreak': settings.showStreak,
|
||||
if (settings.dreamPrompt != _first.dreamPrompt)
|
||||
'dreamPrompt': settings.dreamPrompt,
|
||||
if (settings.libraryLanguages.isNotEmpty)
|
||||
'libraryLanguages': settings.libraryLanguages,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -61,6 +63,13 @@ MeditateSettings? decodeSettings(ByteData payload) => decodeJson(
|
|||
},
|
||||
showStreak: _bool(json['showStreak'], _first.showStreak),
|
||||
dreamPrompt: _bool(json['dreamPrompt'], _first.dreamPrompt),
|
||||
libraryLanguages: switch (json['libraryLanguages']) {
|
||||
final List<Object?> languages => [
|
||||
for (final language in languages)
|
||||
if (language is String) language,
|
||||
],
|
||||
_ => const [],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ final class const MeditateSettings({
|
|||
final List<String> favourites = const [],
|
||||
final bool showStreak = true,
|
||||
final bool dreamPrompt = true,
|
||||
|
||||
/// The spoken languages the library shows, by primary language (`en`, `de`);
|
||||
/// empty for every one.
|
||||
final List<String> libraryLanguages = const [],
|
||||
}) with Equatable {
|
||||
/// The silent timer's shortest length, in minutes.
|
||||
static const minTimerMinutes = 1;
|
||||
|
|
@ -63,6 +67,7 @@ final class const MeditateSettings({
|
|||
List<String>? favourites,
|
||||
bool? showStreak,
|
||||
bool? dreamPrompt,
|
||||
List<String>? libraryLanguages,
|
||||
}) => MeditateSettings(
|
||||
bell: bell ?? this.bell,
|
||||
startBell: startBell ?? this.startBell,
|
||||
|
|
@ -72,6 +77,7 @@ final class const MeditateSettings({
|
|||
favourites: favourites ?? this.favourites,
|
||||
showStreak: showStreak ?? this.showStreak,
|
||||
dreamPrompt: dreamPrompt ?? this.dreamPrompt,
|
||||
libraryLanguages: libraryLanguages ?? this.libraryLanguages,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -84,5 +90,6 @@ final class const MeditateSettings({
|
|||
favourites,
|
||||
showStreak,
|
||||
dreamPrompt,
|
||||
libraryLanguages,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
|
|||
show LibraryCatalogue;
|
||||
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/feature/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_meditate_flutter/src/feature/practice/model/practice.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/feature/practice/widget/practice_texts.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/feature/sitting/bloc/practice_cubit.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/feature/sitting/widget/practice_widgets.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/tabs/tab_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -191,9 +193,16 @@ class _LibraryState() extends State<_Library> {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
final query = widget.query;
|
||||
final chosen = context.select<PracticeCubit, List<String>>(
|
||||
(cubit) => cubit.state.settings.libraryLanguages,
|
||||
);
|
||||
final languages = catalogue.languagesShown(chosen);
|
||||
bool spoken(String language) =>
|
||||
languages.isEmpty || languages.contains(languageKey(language));
|
||||
final courses = [
|
||||
for (final course in catalogue.courses)
|
||||
if ((_category == null || course.category == _category) &&
|
||||
if (spoken(course.language) &&
|
||||
(_category == null || course.category == _category) &&
|
||||
courseMatches(
|
||||
course,
|
||||
query,
|
||||
|
|
@ -203,7 +212,8 @@ class _LibraryState() extends State<_Library> {
|
|||
];
|
||||
final items = [
|
||||
for (final item in catalogue.items)
|
||||
if ((_category == null || item.category == _category) &&
|
||||
if (spoken(item.language) &&
|
||||
(_category == null || item.category == _category) &&
|
||||
itemMatches(
|
||||
item,
|
||||
query,
|
||||
|
|
@ -241,6 +251,12 @@ class _LibraryState() extends State<_Library> {
|
|||
],
|
||||
),
|
||||
),
|
||||
// Asked only where there is a choice (the developer's, 5 Nov 2026).
|
||||
if (catalogue.languagesUsed.length > 1)
|
||||
_SpokenLanguages(
|
||||
languages: catalogue.languagesUsed,
|
||||
chosen: languages,
|
||||
),
|
||||
if (courses.isEmpty && items.isEmpty)
|
||||
Text(
|
||||
l10n.exploreNoMatch,
|
||||
|
|
@ -255,3 +271,57 @@ class _LibraryState() extends State<_Library> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The library's spoken [languages] to choose from, several at once: [chosen]
|
||||
/// shown, none meaning every one, and remembered across the person's devices.
|
||||
class const _SpokenLanguages({
|
||||
required final List<String> languages,
|
||||
required final List<String> chosen,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(l10n.exploreSpokenLanguage),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final language in languages)
|
||||
FilterChip(
|
||||
label: Text(languageName(l10n, language)),
|
||||
selected: chosen.contains(language),
|
||||
onSelected: (selected) => _choose(context, language, selected),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _choose(
|
||||
BuildContext context,
|
||||
String language,
|
||||
bool selected,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final keysMissing = AppLocalizations.of(context).keysMissing;
|
||||
final practice = context.read<PracticeCubit>();
|
||||
try {
|
||||
await practice.writeSettings(
|
||||
practice.state.settings.copyWith(
|
||||
libraryLanguages: selected
|
||||
? [...chosen, language]
|
||||
: [
|
||||
for (final each in chosen)
|
||||
if (each != language) each,
|
||||
],
|
||||
),
|
||||
);
|
||||
} on KeysMissing {
|
||||
messenger.showSnackBar(SnackBar(content: Text(keysMissing)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'package:clock/clock.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
|
||||
show LibraryAudio, LibraryItem;
|
||||
import 'package:halkyon_meditate_flutter/src/app/meditate_app.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
|
||||
|
|
@ -1280,6 +1282,48 @@ void main() {
|
|||
expect(find.text('Library credits'), findsNothing);
|
||||
});
|
||||
|
||||
group('spoken languages (5 Nov 2026)', () {
|
||||
final german = LibraryItem(
|
||||
id: 'abendruhe',
|
||||
title: 'Abendruhe',
|
||||
category: 'meditation',
|
||||
language: 'de-CH',
|
||||
audio: [
|
||||
LibraryAudio(file: 'b.mp3', durationSeconds: 300, size: 1),
|
||||
],
|
||||
licence: 'CC0',
|
||||
credit: 'Niemand',
|
||||
);
|
||||
|
||||
testWidgets('asked only where the library has several', (tester) async {
|
||||
await exploreLibrary(tester);
|
||||
await scrollTo(tester, find.text('From meditate.example.org'));
|
||||
expect(find.text('Spoken language'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('where it has several, the items of those chosen show, '
|
||||
'several at once, none meaning every one', (tester) async {
|
||||
final sample = sampleCatalogue();
|
||||
library.answer = sample.copyWith(items: [...sample.items, german]);
|
||||
await start(tester, usedBefore: true);
|
||||
await goTab(tester, 'Explore');
|
||||
await scrollTo(tester, find.text('Spoken language'));
|
||||
await scrollTo(tester, find.text('Abendruhe'));
|
||||
expect(find.textContaining('· German ·'), findsOneWidget);
|
||||
|
||||
await scrollTo(tester, find.widgetWithText(FilterChip, 'English'));
|
||||
await tester.tap(find.widgetWithText(FilterChip, 'English'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Abendruhe'), findsNothing);
|
||||
expect(find.text('Evening rest'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilterChip, 'German'));
|
||||
await tester.pumpAndSettle();
|
||||
await scrollTo(tester, find.text('Abendruhe'));
|
||||
expect(find.text('Abendruhe'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("Explore shows the instance's courses and items below the "
|
||||
'practices, by category and by the search', (tester) async {
|
||||
await exploreLibrary(tester);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
|
||||
show LibraryCatalogue;
|
||||
import 'package:halkyon_meditate_flutter/src/core/privacy/meditate_keys.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/core/sealed/json_payload.dart';
|
||||
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart';
|
||||
|
|
@ -293,4 +295,31 @@ void main() {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('spoken languages', () {
|
||||
LibraryCatalogue speaking(List<String> languages) {
|
||||
final sample = sampleCatalogue();
|
||||
return sample.copyWith(
|
||||
courses: const [],
|
||||
items: [
|
||||
for (final language in languages)
|
||||
sample.items.first.copyWith(language: language),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
test('are told apart by primary language', () {
|
||||
expect(languageKey('de-CH'), 'de');
|
||||
expect(speaking(['en', 'de-CH', 'de']).languagesUsed, ['de', 'en']);
|
||||
});
|
||||
|
||||
test('are no choice with only one', () {
|
||||
expect(speaking(['de', 'de-AT']).languagesShown(['de']), isEmpty);
|
||||
});
|
||||
|
||||
test('forget what is no longer offered', () {
|
||||
expect(speaking(['en', 'de']).languagesShown(['en', 'fr']), ['en']);
|
||||
expect(speaking(['en', 'de']).languagesShown(['fr']), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,4 +64,17 @@ void main() {
|
|||
expect(once.isFavourite('body-scan'), isTrue);
|
||||
expect(once.toggleFavourite('body-scan').favourites, isEmpty);
|
||||
});
|
||||
|
||||
test("the library's spoken languages are kept; a list of something else "
|
||||
'reads as none', () {
|
||||
const settings = MeditateSettings(libraryLanguages: ['de', 'en']);
|
||||
expect(
|
||||
decodeSettings(encodeSettings(settings))?.libraryLanguages,
|
||||
settings.libraryLanguages,
|
||||
);
|
||||
expect(
|
||||
json(encodeSettings(const MeditateSettings())),
|
||||
isNot(contains('libraryLanguages')),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue