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:
Tizian 「ティツィアーン」 2026-11-05 15:54:12 +01:00
commit 3e69e6b2f7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 287 additions and 4 deletions

View file

@ -207,5 +207,9 @@
"libraryCredits": "Nachweise der Bibliothek", "libraryCredits": "Nachweise der Bibliothek",
"libraryCreditsIntro": "Was die Bibliothek von {server} bietet, wem es jeweils zu verdanken ist und unter welcher Lizenz.", "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.", "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}"
} }

View file

@ -333,5 +333,16 @@
} }
}, },
"libraryGone": "This isn't in the library any more.", "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"
}
}
}
} }

View file

@ -1347,6 +1347,30 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'From the library'** /// **'From the library'**
String get libraryFromLibrary; 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 class _AppLocalizationsDelegate

View file

@ -734,4 +734,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryFromLibrary => 'Aus der Bibliothek'; 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';
}
} }

View file

@ -729,6 +729,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryFromLibrary => 'From the library'; 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`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -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. /// The category of sounds, which may play looped under a silent sitting.
const soundCategory = 'sound'; const soundCategory = 'sound';
@ -52,6 +56,24 @@ extension LibraryCatalogueLookup on LibraryCatalogue {
return null; 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 /// The categories its items and courses are in, in Meditate's order and
/// then the operator's. /// then the operator's.
List<String> get categoriesUsed { List<String> get categoriesUsed {

View file

@ -105,6 +105,11 @@ class _LibraryItemScreenState() extends State<LibraryItemScreen> {
), ),
if (item.speaker case final speaker?) if (item.speaker case final speaker?)
Text(l10n.libraryWith(speaker)), Text(l10n.libraryWith(speaker)),
Text(
l10n.librarySpokenIn(
languageName(l10n, item.language),
),
),
], ],
), ),
), ),

View file

@ -69,10 +69,20 @@ String itemLine(
return [ return [
categoryName(context, catalogue, item.category), categoryName(context, catalogue, item.category),
?item.speaker, ?item.speaker,
if (catalogue.languagesUsed.length > 1) languageName(l10n, item.language),
item.audio.map((audio) => l10n.minutes(minutesOf(audio))).join(', '), item.audio.map((audio) => l10n.minutes(minutesOf(audio))).join(', '),
].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. /// [category]'s badge, as a practice's kind's.
class const CategoryBadge( class const CategoryBadge(
final String category, { final String category, {

View file

@ -26,6 +26,8 @@ ByteData encodeSettings(MeditateSettings settings) => encodeJson(
'showStreak': settings.showStreak, 'showStreak': settings.showStreak,
if (settings.dreamPrompt != _first.dreamPrompt) if (settings.dreamPrompt != _first.dreamPrompt)
'dreamPrompt': settings.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), showStreak: _bool(json['showStreak'], _first.showStreak),
dreamPrompt: _bool(json['dreamPrompt'], _first.dreamPrompt), 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 [],
},
), ),
); );

View file

@ -31,6 +31,10 @@ final class const MeditateSettings({
final List<String> favourites = const [], final List<String> favourites = const [],
final bool showStreak = true, final bool showStreak = true,
final bool dreamPrompt = 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 { }) with Equatable {
/// The silent timer's shortest length, in minutes. /// The silent timer's shortest length, in minutes.
static const minTimerMinutes = 1; static const minTimerMinutes = 1;
@ -63,6 +67,7 @@ final class const MeditateSettings({
List<String>? favourites, List<String>? favourites,
bool? showStreak, bool? showStreak,
bool? dreamPrompt, bool? dreamPrompt,
List<String>? libraryLanguages,
}) => MeditateSettings( }) => MeditateSettings(
bell: bell ?? this.bell, bell: bell ?? this.bell,
startBell: startBell ?? this.startBell, startBell: startBell ?? this.startBell,
@ -72,6 +77,7 @@ final class const MeditateSettings({
favourites: favourites ?? this.favourites, favourites: favourites ?? this.favourites,
showStreak: showStreak ?? this.showStreak, showStreak: showStreak ?? this.showStreak,
dreamPrompt: dreamPrompt ?? this.dreamPrompt, dreamPrompt: dreamPrompt ?? this.dreamPrompt,
libraryLanguages: libraryLanguages ?? this.libraryLanguages,
); );
@override @override
@ -84,5 +90,6 @@ final class const MeditateSettings({
favourites, favourites,
showStreak, showStreak,
dreamPrompt, dreamPrompt,
libraryLanguages,
]; ];
} }

View file

@ -3,11 +3,13 @@ import 'package:halkyon_meditate_client/halkyon_meditate_client.dart'
show LibraryCatalogue; show LibraryCatalogue;
import 'package:halkyon_meditate_flutter/l10n/app_localizations.dart'; 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/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/bloc/library_cubit.dart';
import 'package:halkyon_meditate_flutter/src/feature/library/model/library.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/library/widget/library_widgets.dart';
import 'package:halkyon_meditate_flutter/src/feature/practice/model/practice.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/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/feature/sitting/widget/practice_widgets.dart';
import 'package:halkyon_meditate_flutter/src/tabs/tab_screen.dart'; import 'package:halkyon_meditate_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -191,9 +193,16 @@ class _LibraryState() extends State<_Library> {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final query = widget.query; 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 = [ final courses = [
for (final course in catalogue.courses) for (final course in catalogue.courses)
if ((_category == null || course.category == _category) && if (spoken(course.language) &&
(_category == null || course.category == _category) &&
courseMatches( courseMatches(
course, course,
query, query,
@ -203,7 +212,8 @@ class _LibraryState() extends State<_Library> {
]; ];
final items = [ final items = [
for (final item in catalogue.items) for (final item in catalogue.items)
if ((_category == null || item.category == _category) && if (spoken(item.language) &&
(_category == null || item.category == _category) &&
itemMatches( itemMatches(
item, item,
query, 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) if (courses.isEmpty && items.isEmpty)
Text( Text(
l10n.exploreNoMatch, 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)));
}
}
}

View file

@ -2,6 +2,8 @@ import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.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_app.dart';
import 'package:halkyon_meditate_flutter/src/app/meditate_dependencies.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/core/privacy/meditate_keys.dart';
@ -1280,6 +1282,48 @@ void main() {
expect(find.text('Library credits'), findsNothing); 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 " testWidgets("Explore shows the instance's courses and items below the "
'practices, by category and by the search', (tester) async { 'practices, by category and by the search', (tester) async {
await exploreLibrary(tester); await exploreLibrary(tester);

View file

@ -1,6 +1,8 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.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/privacy/meditate_keys.dart';
import 'package:halkyon_meditate_flutter/src/core/sealed/json_payload.dart'; import 'package:halkyon_meditate_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_meditate_flutter/src/core/sound/guide_player.dart'; import 'package:halkyon_meditate_flutter/src/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);
});
});
} }

View file

@ -64,4 +64,17 @@ void main() {
expect(once.isFavourite('body-scan'), isTrue); expect(once.isFavourite('body-scan'), isTrue);
expect(once.toggleFavourite('body-scan').favourites, isEmpty); 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')),
);
});
} }

View file

@ -287,6 +287,13 @@ names and pictures are its own). Everything below is Halkyon's own.
Caddy forwarding `/library/*` to the web server with the rest, `check_library` Caddy forwarding `/library/*` to the web server with the rest, `check_library`
compiled into the image so an operator checks a folder on the host. compiled into the image so an operator checks a folder on the host.
- **D17 What a recording speaks** (5 Nov 2026, the developer's request): an item's page
says 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 (`libraryLanguages`). Languages group by their primary subtag (`de-CH` with
`de`). A library with one language asks nothing, and a choice no longer offered is
dropped, so that a choice never hides a library.
## Steps ## Steps
1. **The shell** (3 Nov): Todo's copied and renamed, its features left out, the three tabs 1. **The shell** (3 Nov): Todo's copied and renamed, its features left out, the three tabs