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
27 changed files with 684 additions and 220 deletions

View file

@ -10,7 +10,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of (the floating bar and rail, `FoldingTabs`' tab row folding into the title), with texts of its own (English, German).
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput` on `speech_to_text`, `FakeSpeechInput` in `testing.dart`; none on Linux) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), every app's Profile (`HalkyonProfile`, decided 2026-11-02: the person's own tab, the same in every app, with their account, the ways they sign in and their public profile, one for all apps, then the app's own tabs) and people's public profiles (`PublicProfileRepository`, `PublicProfileSection`, `showProfileOf`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), the device's own speech recognition (`SpeechInput`, `DeviceSpeechInput` on `speech_to_text`, `FakeSpeechInput` in `testing.dart`; none on Linux) and dictation into a text at its cursor, listening again after each pause (`Dictation`, Meditate's ADR 0038, D15; an app that listens declares the microphone and its usage texts itself), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), the device's appearance and language, chosen in Settings and applied before anyone signs in (`Look`, `FileLookStore`, `LookCubit`, `DeviceLookCard`; Learn keeps them among its own device settings and shows `LookSettingsCard`; ADR 0014's amendment of 4 Nov 2026), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed, row IDs keyed by the account (`keyedRowId`). SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
- `halkyon_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`.

View file

@ -23,7 +23,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built for the instance in use (`Dependencies.create()`) and exposed through halkyon_app's `AppScope` InheritedWidget, whose parts it provides (`AppDependencies`); widgets reach it through `Dependencies.of(context)`. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by halkyon_app's `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonDependencies.of(context)`.
- **The device's own settings** (appearance, language, citation style, the PDF sidebar's share of the room as last dragged; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` around halkyon_app's `SessionGate` (`LearnSessions`, `lib/src/app/`), whose plain app takes its look from it (theme mode and language, `DeviceSettings.look`), as `PersonApp` does; halkyon_app's `InstanceGate` reads the same two before any instance opens (`readLook`). What should follow the person to their other devices goes into their synced `PersonSettings` instead.
- **The device's own settings** (appearance, language, citation style, the PDF sidebar's share of the room as last dragged; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` around halkyon_app's `SessionGate` (`LearnSessions`, `lib/src/app/`), whose plain app takes its look from it (theme mode and language, `DeviceSettings.look`), as `PersonApp` does; halkyon_app's `InstanceGate` reads the same two before any instance opens (`readLook`). The appearance and language are halkyon_app's model and card (`Appearance`, `Look`, `LookSettingsCard`, fed by `DeviceSettingsCard`), as every app's, but Learn keeps them here rather than in halkyon_app's `look.json`, so that nobody's choice moves (ADR 0014's amendment of 4 Nov 2026). What should follow the person to their other devices goes into their synced `PersonSettings` instead.
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the launch screen, then the start screen. **The gates are halkyon_app's** (ADR 0014, shared by Halkyon's apps): `InstanceGate`, `SessionGate`, `PersonScope`, `AppScope`; Learn gives them `learnFrame` (`lib/src/app/learn_frame.dart`: its name and word, icon, theme, languages, launch screen, and the sentence only it can say, the first start's subtitle, in its own words), its dependencies, `ServerpodInstanceChecker(app: 'learn')` and its `CountryNames` (`main.dart`).
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), saving files the app made, such as exports (`FileSaver`: `file_selector`'s dialog on the desktop, `file_picker`'s on Android and iOS, where `file_selector` has none; halkyon_app's `writePrivately` writes them the own user's alone, on macOS into the chosen file itself, which the sandbox's `files.user-selected.read-write` entitlement allows: keep it, or nothing saves there), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), cutting Japanese on the device (`JapaneseAnalyser`: MeCab, which needs the IPADIC the app downloads, on an isolate of its own), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), zipping an export with its files on another isolate (`ExportArchiver`, for the same reason), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests).
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.

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'));
});
}

View file

@ -117,3 +117,34 @@ every service of an instance (ADR 0016). The app side was Learn's alone; it move
- **Chosen without the developer, flagged:** the person's tab comes first in Train too,
where Profile used to open on Progress, so that every app opens it alike.
## Amendment (4 Nov 2026): every app's appearance and language
The developer asked for Todo, Train and Meditate to offer what Learn's Settings offer
first: the app's appearance (the device's theme, light, dark) and its language (the
device's, or one the app speaks, each named in its own language). They had passed a fixed
look (the system's theme and language). It moved into `halkyon_app`:
- **The model** (`Appearance`, `Look`, `AppLanguage`, `localeOfTag`): the appearance and a
BCP 47 tag, null for the device's language, as Learn's device settings kept them.
- **Kept on the device, per device** (`FileLookStore`, `<app support>/look.json`): not
synced, read before the first frame and applied before anyone signs in, as Learn's.
Alternatives: the person's synced settings (they would apply only after signing in, and
the start screens would speak another language than the app); shared preferences (a
plugin more, where a small JSON file written by rename does, as the instances and
what's new are kept).
- **Applied** by a `LookCubit` that each app opens in `main` and provides above
`InstanceGate` (its `readLook`), so `SessionGate`'s start screens and the person's
`MaterialApp` follow it at once.
- **Shown** by `DeviceLookCard` (on the cubit) or `LookSettingsCard` (given the look and
what to do with a choice): two menus, side by side from medium windows, the texts
halkyon_app's (`look*`, moved from Learn's `settingsAppearance*`, `settingsLanguage*`,
the same words). Each app passes its own languages (`todoLanguages`, `trainLanguages`,
`meditateLanguages`, Learn's `appLanguages`), today all three it speaks: English (UK),
English (US), Deutsch. The card comes first of the app's own settings, right after the
Sync and operator's cards, as in Learn.
- **Learn keeps its stored choice where it is**: the appearance and language stay among
its `DeviceSettings` (`device_settings.json`), its card feeding the shared one. Chosen
over moving them into `look.json` and reading the old values once, which is less code in
the end but a migration that, done wrong, loses someone's language; keeping them needs
none and changes nothing anyone sees. Moving them can follow later, once.

View file

@ -10,6 +10,10 @@
/// `PersonScope` opens each person's own dependencies. While an account
/// waits for the instance's operator, `WaitScreen` shows where its wait
/// stands (Halkyon Learn's ADR 0021, D31).
/// - **The look**: the device's appearance and language (`Look`), kept on
/// it (`FileLookStore`) and applied before anyone signs in (`LookCubit`),
/// chosen in Settings (`DeviceLookCard`, `LookSettingsCard`; ADR 0014's
/// amendment of 4 Nov 2026).
/// - **The account and About**: `AccountTiles` for an app's settings,
/// `showAppAbout` with the source of what the instance runs, and
/// `resolveAppLocale`, the language every Halkyon app picks.
@ -118,6 +122,10 @@ export 'src/instance/widget/instance_location.dart';
export 'src/instance/widget/instance_location_words.dart';
export 'src/instance/widget/instance_step.dart';
export 'src/instance/widget/instance_tile.dart';
export 'src/look/bloc/look_cubit.dart';
export 'src/look/data/look_store.dart';
export 'src/look/model/look.dart';
export 'src/look/widget/look_settings_card.dart';
export 'src/operator/bloc/account_suggestions_cubit.dart';
export 'src/operator/bloc/operating_cubit.dart';
export 'src/operator/bloc/operator_list_cubit.dart';

View file

@ -516,5 +516,11 @@
"publicProfileVerifyHint": "Ein Link bekommt ein Häkchen, sobald seine Seite mit rel=\"me\" auf deine Profilseite zurückverlinkt, wie Mastodon Links prüft.",
"profileAccount": "Konto",
"profileSheetLinkVerified": "Verlinkt zurück auf dieses Profil",
"speechNoRecognizer": "Auf diesem Gerät ist keine Spracherkennung installiert. Installiere eine, etwa FUTO Voice Input oder Googles Sprachdienste, und versuche es dann noch einmal."
"speechNoRecognizer": "Auf diesem Gerät ist keine Spracherkennung installiert. Installiere eine, etwa FUTO Voice Input oder Googles Sprachdienste, und versuche es dann noch einmal.",
"lookAppearance": "Darstellung",
"lookAppearanceDevice": "Gerätedesign",
"lookAppearanceLight": "Hell",
"lookAppearanceDark": "Dunkel",
"lookLanguage": "Sprache",
"lookLanguageDevice": "Gerätesprache"
}

View file

@ -1585,5 +1585,11 @@
"profileAccount": "Account",
"profileSheetLinkVerified": "Links back to this profile",
"speechNoRecognizer": "This device has no speech recognition installed. Install one, such as FUTO Voice Input or Google's speech services, then try again.",
"@speechNoRecognizer": {"description": "Shown when the microphone is tapped on a device without any speech recognition app (as GrapheneOS without Google's services), with a hint how to get one."}
"@speechNoRecognizer": {"description": "Shown when the microphone is tapped on a device without any speech recognition app (as GrapheneOS without Google's services), with a hint how to get one."},
"lookAppearance": "Appearance",
"lookAppearanceDevice": "Device theme",
"lookAppearanceLight": "Light",
"lookAppearanceDark": "Dark",
"lookLanguage": "Language",
"lookLanguageDevice": "Device language"
}

View file

@ -3204,6 +3204,42 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'This device has no speech recognition installed. Install one, such as FUTO Voice Input or Google\'s speech services, then try again.'**
String get speechNoRecognizer;
/// No description provided for @lookAppearance.
///
/// In en, this message translates to:
/// **'Appearance'**
String get lookAppearance;
/// No description provided for @lookAppearanceDevice.
///
/// In en, this message translates to:
/// **'Device theme'**
String get lookAppearanceDevice;
/// No description provided for @lookAppearanceLight.
///
/// In en, this message translates to:
/// **'Light'**
String get lookAppearanceLight;
/// No description provided for @lookAppearanceDark.
///
/// In en, this message translates to:
/// **'Dark'**
String get lookAppearanceDark;
/// No description provided for @lookLanguage.
///
/// In en, this message translates to:
/// **'Language'**
String get lookLanguage;
/// No description provided for @lookLanguageDevice.
///
/// In en, this message translates to:
/// **'Device language'**
String get lookLanguageDevice;
}
class _HalkyonAppLocalizationsDelegate

View file

@ -2015,4 +2015,22 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get speechNoRecognizer =>
'Auf diesem Gerät ist keine Spracherkennung installiert. Installiere eine, etwa FUTO Voice Input oder Googles Sprachdienste, und versuche es dann noch einmal.';
@override
String get lookAppearance => 'Darstellung';
@override
String get lookAppearanceDevice => 'Gerätedesign';
@override
String get lookAppearanceLight => 'Hell';
@override
String get lookAppearanceDark => 'Dunkel';
@override
String get lookLanguage => 'Sprache';
@override
String get lookLanguageDevice => 'Gerätesprache';
}

View file

@ -1983,6 +1983,24 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get speechNoRecognizer =>
'This device has no speech recognition installed. Install one, such as FUTO Voice Input or Google\'s speech services, then try again.';
@override
String get lookAppearance => 'Appearance';
@override
String get lookAppearanceDevice => 'Device theme';
@override
String get lookAppearanceLight => 'Light';
@override
String get lookAppearanceDark => 'Dark';
@override
String get lookLanguage => 'Language';
@override
String get lookLanguageDevice => 'Device language';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -0,0 +1,35 @@
import 'dart:developer';
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/look/data/look_store.dart';
import 'package:halkyon_app/src/look/model/look.dart';
/// The device's look of the app, starting at what it kept: a choice applies
/// at once and is kept for the next start. An app opens it before its first
/// frame ([open]) and provides it above `InstanceGate`, so that the start
/// screens and every person's app follow it.
class LookCubit(final LookStore _store, super.initialState)
extends Cubit<Look> {
/// The look [store] kept, to go on from.
static Future<LookCubit> open(LookStore store) async =>
LookCubit(store, await store.read());
/// Shows the app in [appearance].
Future<void> chooseAppearance(Appearance appearance) =>
_apply(state.withAppearance(appearance));
/// Speaks [language] (a BCP 47 tag), or the device's where null.
Future<void> chooseLanguage(String? language) =>
_apply(state.withLanguage(language));
Future<void> _apply(Look look) async {
emit(look);
try {
await _store.write(look);
} on FileSystemException catch (error) {
// It still applies until the app closes.
log('Keeping the look failed', name: 'Look', error: error);
}
}
}

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