feat(app,learn): the device's appearance and language, halkyon_app's, as Learn's Settings show them

The developer asked for Todo, Train and Meditate to offer Learn's
Appearance and Language (ADR 0014's amendment of 4 Nov 2026). Their
model, store, cubit and card move into halkyon_app: Appearance and Look
(a BCP 47 tag, null for the device's language), FileLookStore keeping it
in the app's support folder as JSON written by rename (per device, not
synced, read before the first frame), LookCubit for the apps to provide
above InstanceGate, and LookSettingsCard / DeviceLookCard with the texts
moved from Learn's arbs (look*, the same words). Each app passes the
languages it speaks, named in their own language.

Learn keeps its stored choice where it was, among its DeviceSettings,
and feeds the shared card from them: no migration, so nobody's language
or appearance moves, and nothing changes on its screen.

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-04 22:30:29 +01:00
commit 23d3bcb4ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 684 additions and 220 deletions

View file

@ -502,12 +502,6 @@
"settingsDeleteMessage": "Alles, was du hast, also deine Lernsets, Karten und Wiederholungen, wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"settingsAbout": "Über Halkyon Learn",
"aboutLegalese": "Freie Software: Die App steht unter der GNU Affero General Public License, Version 3 oder jeder späteren Version, der Server unter der Server Side Public License, Version 1.",
"settingsAppearance": "Darstellung",
"settingsAppearanceDevice": "Gerätedesign",
"settingsAppearanceLight": "Hell",
"settingsAppearanceDark": "Dunkel",
"settingsLanguage": "Sprache",
"settingsLanguageDevice": "Gerätesprache",
"settingsCitations": "Quellenangaben",
"settingsCitationsDescription": "Wie Quellen und die Literaturverzeichnisse von Lernsets auf diesem Gerät zitiert werden. Gib die vollständigen Namen der Autoren ein: Jeder Stil kürzt und ordnet sie selbst.",
"settingsCitationStyle": "Stil",

View file

@ -575,12 +575,6 @@
"settingsDeleteMessage": "Everything you have, your studysets, cards and reviews, is deleted on the server and on all your devices. This can't be undone.",
"settingsAbout": "About Halkyon Learn",
"aboutLegalese": "Free software: the app is under the GNU Affero General Public License, version 3 or any later version, the server under the Server Side Public License, version 1.",
"settingsAppearance": "Appearance",
"settingsAppearanceDevice": "Device theme",
"settingsAppearanceLight": "Light",
"settingsAppearanceDark": "Dark",
"settingsLanguage": "Language",
"settingsLanguageDevice": "Device language",
"settingsCitations": "Citations",
"settingsCitationsDescription": "How sources and studysets' bibliographies are cited on this device. Type authors' full names: each style abbreviates and orders them itself.",
"settingsCitationStyle": "Style",

View file

@ -3016,42 +3016,6 @@ abstract class AppLocalizations {
/// **'Free software: the app is under the GNU Affero General Public License, version 3 or any later version, the server under the Server Side Public License, version 1.'**
String get aboutLegalese;
/// No description provided for @settingsAppearance.
///
/// In en, this message translates to:
/// **'Appearance'**
String get settingsAppearance;
/// No description provided for @settingsAppearanceDevice.
///
/// In en, this message translates to:
/// **'Device theme'**
String get settingsAppearanceDevice;
/// No description provided for @settingsAppearanceLight.
///
/// In en, this message translates to:
/// **'Light'**
String get settingsAppearanceLight;
/// No description provided for @settingsAppearanceDark.
///
/// In en, this message translates to:
/// **'Dark'**
String get settingsAppearanceDark;
/// No description provided for @settingsLanguage.
///
/// In en, this message translates to:
/// **'Language'**
String get settingsLanguage;
/// No description provided for @settingsLanguageDevice.
///
/// In en, this message translates to:
/// **'Device language'**
String get settingsLanguageDevice;
/// No description provided for @settingsCitations.
///
/// In en, this message translates to:

View file

@ -1832,24 +1832,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutLegalese =>
'Freie Software: Die App steht unter der GNU Affero General Public License, Version 3 oder jeder späteren Version, der Server unter der Server Side Public License, Version 1.';
@override
String get settingsAppearance => 'Darstellung';
@override
String get settingsAppearanceDevice => 'Gerätedesign';
@override
String get settingsAppearanceLight => 'Hell';
@override
String get settingsAppearanceDark => 'Dunkel';
@override
String get settingsLanguage => 'Sprache';
@override
String get settingsLanguageDevice => 'Gerätesprache';
@override
String get settingsCitations => 'Quellenangaben';

View file

@ -1816,24 +1816,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutLegalese =>
'Free software: the app is under the GNU Affero General Public License, version 3 or any later version, the server under the Server Side Public License, version 1.';
@override
String get settingsAppearance => 'Appearance';
@override
String get settingsAppearanceDevice => 'Device theme';
@override
String get settingsAppearanceLight => 'Light';
@override
String get settingsAppearanceDark => 'Dark';
@override
String get settingsLanguage => 'Language';
@override
String get settingsLanguageDevice => 'Device language';
@override
String get settingsCitations => 'Citations';

View file

@ -1,13 +1,11 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_app/halkyon_app.dart' show Appearance;
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
/// How the app looks: as the device does, or always light or dark.
enum Appearance() {
device,
light,
dark,
}
// How the app looks: halkyon_app's, as every Halkyon app's (ADR 0014's
// amendment of 4 Nov 2026); kept here, among the device's own settings.
export 'package:halkyon_app/halkyon_app.dart' show Appearance;
/// The settings of one device, which stay on it (decided 2026-10-07): how the
/// app looks, the language it speaks, the style it cites sources in,

View file

@ -3,7 +3,6 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:material_ui/material_ui.dart';
/// Provides the device's settings ([DeviceSettingsBloc]) to [child], starting
@ -26,22 +25,19 @@ class const DeviceSettingsScope({required final Widget child, super.key})
}
}
/// What the device's settings mean for a `MaterialApp`.
/// What the device's settings mean for a `MaterialApp`: their appearance and
/// language, as halkyon_app's [Look] means them for every Halkyon app.
extension DeviceSettingsApp on DeviceSettings {
/// The device's look of the app.
Look get appLook => Look(appearance: appearance, language: language);
/// The app's theme mode.
ThemeMode get themeMode => switch (appearance) {
Appearance.device => ThemeMode.system,
Appearance.light => ThemeMode.light,
Appearance.dark => ThemeMode.dark,
};
ThemeMode get themeMode => appLook.themeMode;
/// The app's locale; null for the device's.
Locale? get locale => switch (language) {
final language? => localeOf(language),
null => null,
};
Locale? get locale => appLook.locale;
/// What halkyon_app's own screens follow of them while no person's app
/// shows.
LookSettings get look => (themeMode: themeMode, locale: locale);
LookSettings get look => appLook.settings;
}

View file

@ -23,21 +23,10 @@ const appLocalizationsDelegates = <LocalizationsDelegate<Object?>>[
/// lists them, each named in its own language (its autonym), as language
/// pickers name them: someone looking for their language may not read the
/// one the app speaks now. `tag` is the language's BCP 47 tag, as the
/// device's settings keep it.
const appLanguages = [
/// device's settings keep it; halkyon_app's `localeOfTag` makes it a
/// locale.
const appLanguages = <AppLanguage>[
(tag: 'en-GB', name: 'English (UK)'),
(tag: 'en-US', name: 'English (US)'),
(tag: 'de', name: 'Deutsch'),
];
/// The English the app speaks wherever it can't speak the language asked for:
/// British English, which `app_en.arb` holds (as halkyon_app's
/// `resolveAppLocale` falls back to).
const _fallback = Locale('en', 'GB');
/// The locale of the BCP 47 [tag], as `en-GB`.
Locale localeOf(String tag) => switch (tag.split('-')) {
[final language, final country, ...] => Locale(language, country),
[final language, ...] => Locale(language),
_ => _fallback,
};

View file

@ -1,106 +1,26 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// How this device shows the app: its appearance and language, side by side
/// where there is room. Both stay on the device.
/// where there is room, as every Halkyon app's Settings show them
/// (halkyon_app's [LookSettingsCard]). Both stay on the device, among its
/// own settings ([DeviceSettingsBloc]), where Learn kept them before the
/// other apps had them.
class const DeviceSettingsCard({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final settings = context.watch<DeviceSettingsBloc>().state;
final appearance = _Field(
label: l10n.settingsAppearance,
field: DropdownButtonFormField<Appearance>(
initialValue: settings.appearance,
decoration: _decoration,
icon: const Icon(LucideIcons.chevronDown),
items: [
for (final appearance in Appearance.values)
DropdownMenuItem(
value: appearance,
child: Text(switch (appearance) {
Appearance.device => l10n.settingsAppearanceDevice,
Appearance.light => l10n.settingsAppearanceLight,
Appearance.dark => l10n.settingsAppearanceDark,
}),
),
],
onChanged: (appearance) {
if (appearance != null) {
context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventAppearanceChosen(appearance),
);
}
},
),
);
final language = _Field(
label: l10n.settingsLanguage,
field: DropdownButtonFormField<String?>(
// A language this version no longer speaks shows as the device's.
initialValue:
appLanguages.any((language) => language.tag == settings.language)
? settings.language
: null,
decoration: _decoration,
icon: const Icon(LucideIcons.chevronDown),
items: [
DropdownMenuItem(child: Text(l10n.settingsLanguageDevice)),
for (final (:tag, :name) in appLanguages)
DropdownMenuItem(value: tag, child: Text(name)),
],
onChanged: (language) => context.read<DeviceSettingsBloc>().add(
DeviceSettingsEventLanguageChosen(language),
),
),
);
return SectionCard(
child: context.windowSize.isAtLeastMedium
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: appearance),
const SizedBox(width: 16),
Expanded(child: language),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [appearance, const SizedBox(height: 16), language],
),
);
}
}
const _decoration = InputDecoration(
isDense: true,
border: OutlineInputBorder(),
);
/// A setting's label above its field.
class const _Field({required final String label, required final Widget field})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
field,
],
final settings = context.watch<DeviceSettingsBloc>();
return LookSettingsCard(
look: settings.state.appLook,
languages: appLanguages,
onAppearanceChosen: (appearance) =>
settings.add(DeviceSettingsEventAppearanceChosen(appearance)),
onLanguageChosen: (language) =>
settings.add(DeviceSettingsEventLanguageChosen(language)),
);
}
}

View file

@ -9,7 +9,7 @@ void main() {
// tested there; Learn's own languages are tested here.
test('every language offered is one the app speaks', () {
for (final language in appLanguages) {
final locale = localeOf(language.tag);
final locale = localeOfTag(language.tag);
expect(
resolveAppLocale([locale], AppLocalizations.supportedLocales),
isIn(AppLocalizations.supportedLocales),
@ -28,9 +28,4 @@ void main() {
'Color',
);
});
test('a language tag becomes its locale', () {
expect(localeOf('de'), const Locale('de'));
expect(localeOf('en-GB'), const Locale('en', 'GB'));
});
}