feat(train): Settings choose the app's appearance and language

The developer asked for Train to offer what Learn's Settings offer first
(ADR 0014's amendment of 4 Nov 2026). Train passed a fixed look, the
system's theme and language; it now opens halkyon_app's LookCubit on
<app support>/look.json in main, provides it above InstanceGate, and its
start screens and each person's MaterialApp follow it. Settings show
DeviceLookCard right after the Sync and operator's cards, as Learn's,
offering English (UK), English (US) and Deutsch (trainLanguages).

The app's tests tapping Delete account and About now scroll them into
view first (tapInSettings, as Todo's and Meditate's): the card moved
them below the phone's fold.

Whats-New: Settings: choose the app's appearance and language.
Whats-New-De: Einstellungen: Darstellung und Sprache der App wählen.

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 7aba2a2dcc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 187 additions and 104 deletions

File diff suppressed because one or more lines are too long

View file

@ -32,6 +32,11 @@ Future<void> main() async {
sharesStorage: device.storage.sharesWith, sharesStorage: device.storage.sharesWith,
); );
await instances.load(usedBefore: device.storage.usedBefore()); await instances.load(usedBefore: device.storage.usedBefore());
// The device's appearance and language, read before the first frame so
// that it opens in them.
final look = await LookCubit.open(
FileLookStore(File(p.join(device.support, 'look.json'))),
);
runApp( runApp(
WhatsNewScope( WhatsNewScope(
whatsNew: WhatsNew( whatsNew: WhatsNew(
@ -47,6 +52,7 @@ Future<void> main() async {
// answers too, but holds nothing of this app's. // answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'train'), checker: ServerpodInstanceChecker(app: 'train'),
open: (instance) => TrainDependencies.create(instance, device), open: (instance) => TrainDependencies.create(instance, device),
look: look,
), ),
), ),
); );

View file

@ -25,10 +25,6 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The device's look until Train has settings: the system's theme mode and
/// language.
const _systemLook = (themeMode: ThemeMode.system, locale: null);
/// Halkyon Train: halkyon_app's [InstanceGate] (the first start, the account /// Halkyon Train: halkyon_app's [InstanceGate] (the first start, the account
/// on each instance) with Train's [trainFrame], and on the instance in use /// on each instance) with Train's [trainFrame], and on the instance in use
/// halkyon_app's [SessionGate], which shows each signed-in person their own /// halkyon_app's [SessionGate], which shows each signed-in person their own
@ -47,19 +43,27 @@ class const TrainApp({
/// Train's dependencies on an instance. /// Train's dependencies on an instance.
required final Future<TrainDependencies> Function(Instance instance) open, required final Future<TrainDependencies> Function(Instance instance) open,
/// The device's appearance and language, chosen in Settings (ADR 0014's
/// amendment of 4 Nov 2026): above everything, as they apply before
/// anyone signs in.
required final LookCubit look,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => InstanceGate( Widget build(BuildContext context) => BlocProvider.value(
frame: trainFrame, value: look,
choice: choice, child: InstanceGate(
checker: checker, frame: trainFrame,
countryNames: countryNames, choice: choice,
open: open, checker: checker,
readLook: () async => _systemLook, countryNames: countryNames,
app: SessionGate( open: open,
look: (context) => _systemLook, readLook: () async => look.state.settings,
app: (person) => TrainPersonApp(person: person), app: SessionGate(
look: (context) => context.watch<LookCubit>().state.settings,
app: (person) => TrainPersonApp(person: person),
),
), ),
); );
} }
@ -88,89 +92,95 @@ class _TrainPersonAppState() extends State<TrainPersonApp> {
} }
@override @override
Widget build(BuildContext context) => MaterialApp.router( Widget build(BuildContext context) {
routerConfig: _router, // The device's look and language, chosen in Settings.
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, final look = context.watch<LookCubit>().state;
theme: appTheme(Brightness.light), return MaterialApp.router(
darkTheme: appTheme(Brightness.dark), routerConfig: _router,
localizationsDelegates: trainLocalizationsDelegates, onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
supportedLocales: AppLocalizations.supportedLocales, theme: appTheme(Brightness.light),
localeListResolutionCallback: resolveAppLocale, darkTheme: appTheme(Brightness.dark),
// How many accounts wait, for whoever operates the instance: the themeMode: look.themeMode,
// account sheet's tiles and the badge on its button. The workouts above locale: look.locale,
// the navigator, so home and a workout's page share their deletions. localizationsDelegates: trainLocalizationsDelegates,
builder: (context, child) => PersonScope( supportedLocales: AppLocalizations.supportedLocales,
person: widget.person, localeListResolutionCallback: resolveAppLocale,
child: MultiBlocProvider( // How many accounts wait, for whoever operates the instance: the
providers: [ // account sheet's tiles and the badge on its button. The workouts above
BlocProvider( // the navigator, so home and a workout's page share their deletions.
create: (context) => builder: (context, child) => PersonScope(
OperatingCubit(AppScope.of(context).instanceOperators), person: widget.person,
), child: MultiBlocProvider(
// Where sync stands: Settings' card and the dot on the cog, as providers: [
// Learn's and Todo's. BlocProvider(
BlocProvider( create: (context) =>
create: (context) => OperatingCubit(AppScope.of(context).instanceOperators),
SyncStatusBloc(PersonScope.of<TrainPerson>(context).sync), ),
), // Where sync stands: Settings' card and the dot on the cog, as
BlocProvider( // Learn's and Todo's.
create: (context) { BlocProvider(
final train = PersonScope.of<TrainPerson>(context); create: (context) =>
return WorkoutsCubit(train.workouts, train.deletions)..open(); SyncStatusBloc(PersonScope.of<TrainPerson>(context).sync),
}, ),
), BlocProvider(
BlocProvider( create: (context) {
create: (context) { final train = PersonScope.of<TrainPerson>(context);
final train = PersonScope.of<TrainPerson>(context); return WorkoutsCubit(train.workouts, train.deletions)..open();
return DaysCubit(train.days, train.loop)..open(); },
}, ),
), BlocProvider(
BlocProvider( create: (context) {
create: (context) { final train = PersonScope.of<TrainPerson>(context);
final train = PersonScope.of<TrainPerson>(context); return DaysCubit(train.days, train.loop)..open();
return PlansCubit(train.plans, train.planDeletions)..open(); },
}, ),
), BlocProvider(
// The places and the strength plans, which the plans' lists, a create: (context) {
// session and Equipment share (ADR 0034, ADR 0035). final train = PersonScope.of<TrainPerson>(context);
BlocProvider( return PlansCubit(train.plans, train.planDeletions)..open();
create: (context) { },
final train = PersonScope.of<TrainPerson>(context); ),
return PlacesCubit(train.places, train.chosenPlace)..open(); // The places and the strength plans, which the plans' lists, a
}, // session and Equipment share (ADR 0034, ADR 0035).
), BlocProvider(
// Their weights, which Progress and Your body share (D64). create: (context) {
BlocProvider( final train = PersonScope.of<TrainPerson>(context);
create: (context) { return PlacesCubit(train.places, train.chosenPlace)..open();
final train = PersonScope.of<TrainPerson>(context); },
return WeightsCubit(train.weights, train.profile)..open(); ),
}, // Their weights, which Progress and Your body share (D64).
), BlocProvider(
BlocProvider( create: (context) {
create: (context) { final train = PersonScope.of<TrainPerson>(context);
final train = PersonScope.of<TrainPerson>(context); return WeightsCubit(train.weights, train.profile)..open();
return StrengthPlansCubit( },
train.strengthPlans, ),
train.strengthPlanDeletions, BlocProvider(
)..open(); create: (context) {
}, final train = PersonScope.of<TrainPerson>(context);
), return StrengthPlansCubit(
BlocProvider( train.strengthPlans,
create: (context) { train.strengthPlanDeletions,
final train = PersonScope.of<TrainPerson>(context); )..open();
return RoutesCubit(train.routes, train.routeDeletions)..open(); },
}, ),
), BlocProvider(
], create: (context) {
child: _Recording( final train = PersonScope.of<TrainPerson>(context);
child: ScreenColor( return RoutesCubit(train.routes, train.routeDeletions)..open();
color: PaletteColor.carrot.background, },
child: child!, ),
],
child: _Recording(
child: ScreenColor(
color: PaletteColor.carrot.background,
child: child!,
),
), ),
), ),
), ),
), );
); }
} }
/// The person's recording (ADR 0030, D21) above [child], where the device /// The person's recording (ADR 0030, D21) above [child], where the device

View file

@ -13,3 +13,13 @@ const trainLocalizationsDelegates = <LocalizationsDelegate<Object?>>[
HalkyonUiLocalizations.delegate, HalkyonUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates, ...GlobalMaterialLocalizations.delegates,
]; ];
/// The languages a person can choose for the app in Settings
/// (`DeviceLookCard`), in the order the picker lists them, each named in
/// its own language: every one the app speaks (`app_en_GB.arb`,
/// `app_en_US.arb`, `app_de.arb`).
const trainLanguages = <AppLanguage>[
(tag: 'en-GB', name: 'English (UK)'),
(tag: 'en-US', name: 'English (US)'),
(tag: 'de', name: 'Deutsch'),
];

View file

@ -3,6 +3,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart'; import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart'; import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart'; import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/app/train_paths.dart'; import 'package:halkyon_train_flutter/src/app/train_paths.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/widget/equipment_screen.dart'; import 'package:halkyon_train_flutter/src/feature/equipment/widget/equipment_screen.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart'; import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
@ -29,7 +30,7 @@ class const SettingsButton({super.key}) extends StatelessWidget {
} }
/// Settings, over the tabs (ADR 0030, D60), grouped in cards as Learn's: /// Settings, over the tabs (ADR 0030, D60), grouped in cards as Learn's:
/// where sync stands, with Sync now; the operator's card for whoever operates the instance; the training's /// where sync stands, with Sync now; the operator's card for whoever operates the instance; how this device shows the app (appearance, language); the training's
/// screens (the body's figures, Health Connect, the Polar Loop, music, /// screens (the body's figures, Health Connect, the Polar Loop, music,
/// those the device has); the account, near the foot (halkyon_app's /// those the device has); the account, near the foot (halkyon_app's
/// sections: whose it is on which instance, switching, signing out and /// sections: whose it is on which instance, switching, signing out and
@ -74,6 +75,11 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
// the instance alone (Halkyon Learn's ADR 0021, D34), // the instance alone (Halkyon Learn's ADR 0021, D34),
// first, as the cog's count leads here. // first, as the cog's count leads here.
const InstanceOperatorCard(below: 12), const InstanceOperatorCard(below: 12),
// How this device shows the app, its appearance and
// language, first of the app's own, as Learn's
// (halkyon_app's, ADR 0014's amendment of 4 Nov 2026).
const DeviceLookCard(languages: trainLanguages),
const SizedBox(height: 12),
SectionCard( SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Column( child: Column(

View file

@ -95,12 +95,16 @@ import '../support/memory_workout_gateway.dart';
void main() { void main() {
final builtIn = Instance.parse('train.example.org')!; final builtIn = Instance.parse('train.example.org')!;
late MemoryInstanceStore store; late MemoryInstanceStore store;
/// The device's appearance and language, kept from start to start.
late MemoryLookStore looks;
late FakeAuthGateway gateway; late FakeAuthGateway gateway;
late List<SignedIn> opened; late List<SignedIn> opened;
late MemorySignInAddresses addresses; late MemorySignInAddresses addresses;
setUp(() { setUp(() {
store = MemoryInstanceStore(); store = MemoryInstanceStore();
looks = MemoryLookStore();
gateway = FakeAuthGateway(); gateway = FakeAuthGateway();
addresses = MemorySignInAddresses(); addresses = MemorySignInAddresses();
opened = []; opened = [];
@ -156,10 +160,13 @@ void main() {
if (!usedBefore || signedOut) { if (!usedBefore || signedOut) {
gateway.revokeSession(); gateway.revokeSession();
} }
final look = await LookCubit.open(looks);
addTearDown(look.close);
await tester.pumpWidget( await tester.pumpWidget(
TrainApp( TrainApp(
choice: choice, choice: choice,
checker: FakeInstanceChecker({builtIn}), checker: FakeInstanceChecker({builtIn}),
look: look,
countryNames: MemoryCountryNames(const {}), countryNames: MemoryCountryNames(const {}),
open: (instance) async => TrainDependencies( open: (instance) async => TrainDependencies(
serverName: instance.name, serverName: instance.name,
@ -373,6 +380,54 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
/// Taps [text] in Settings, scrolled into view: the account and About
/// are near the foot, below the device's look and Train's own screens.
Future<void> tapInSettings(WidgetTester tester, String text) async {
await tester.ensureVisible(find.text(text).first);
await tester.pumpAndSettle();
await tester.tap(find.text(text).first);
await tester.pumpAndSettle();
}
testWidgets("Settings choose the app's appearance and language, first of "
"Train's own: a choice applies at once and stays after a restart", (
tester,
) async {
/// Opens the menu showing [current] and picks [choice].
Future<void> choose(String current, String choice) async {
await tester.tap(find.text(current));
await tester.pumpAndSettle();
await tester.tap(find.text(choice).last);
await tester.pumpAndSettle();
}
Brightness brightness() =>
Theme.of(tester.element(find.byType(Scaffold).last)).brightness;
await start(tester, usedBefore: true);
await openSettings(tester);
expect(find.text('Appearance'), findsOneWidget);
expect(find.text('Language'), findsOneWidget);
expect(brightness(), Brightness.light);
await choose('Device theme', 'Dark');
expect(brightness(), Brightness.dark);
await choose('Device language', 'Deutsch');
expect(find.widgetWithText(AppBar, 'Einstellungen'), findsOneWidget);
expect(find.text('Darstellung'), findsOneWidget);
expect(looks.look, const Look(appearance: Appearance.dark, language: 'de'));
// The app closed and opened again, on the look the device kept.
await tester.pumpWidget(const SizedBox());
await start(tester, usedBefore: true);
expect(brightness(), Brightness.dark);
expect(
Localizations.localeOf(tester.element(find.byType(Scaffold).last)),
const Locale('de'),
);
});
testWidgets("a new install chooses its instance first, in Train's words and " testWidgets("a new install chooses its instance first, in Train's words and "
'look', (tester) async { 'look', (tester) async {
await start(tester); await start(tester);
@ -621,15 +676,13 @@ void main() {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);
await openSettings(tester); await openSettings(tester);
await tester.tap(find.text('Delete account')); await tapInSettings(tester, 'Delete account');
await tester.pumpAndSettle();
expect(find.text('Delete your account?'), findsOneWidget); expect(find.text('Delete your account?'), findsOneWidget);
await tester.tap(find.text('Cancel')); await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(gateway.accountDeletions, 0); expect(gateway.accountDeletions, 0);
await tester.tap(find.text('Delete account')); await tapInSettings(tester, 'Delete account');
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last); await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -643,8 +696,7 @@ void main() {
await openSettings(tester); await openSettings(tester);
gateway.deletionError = Exception('No connection'); gateway.deletionError = Exception('No connection');
await tester.tap(find.text('Delete account')); await tapInSettings(tester, 'Delete account');
await tester.pumpAndSettle();
await tester.tap(find.text('Delete account').last); await tester.tap(find.text('Delete account').last);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -664,8 +716,7 @@ void main() {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);
await openSettings(tester); await openSettings(tester);
await tester.tap(find.text('About Halkyon Train')); await tapInSettings(tester, 'About Halkyon Train');
await tester.pumpAndSettle();
expect(find.text('The source of what this instance runs:'), findsOneWidget); expect(find.text('The source of what this instance runs:'), findsOneWidget);
expect( expect(