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:
parent
a7638e5e54
commit
23d3bcb4ca
14 changed files with 684 additions and 220 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
35
packages/halkyon_app/lib/src/look/bloc/look_cubit.dart
Normal file
35
packages/halkyon_app/lib/src/look/bloc/look_cubit.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
64
packages/halkyon_app/lib/src/look/data/look_store.dart
Normal file
64
packages/halkyon_app/lib/src/look/data/look_store.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_app/src/look/model/look.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
|
||||
/// Where a device keeps its look of the app.
|
||||
abstract interface class LookStore() {
|
||||
/// The look kept; the device's (`Look()`) when none is, or it can't be
|
||||
/// read.
|
||||
Future<Look> read();
|
||||
|
||||
/// Keeps [look] in place of the one kept before.
|
||||
Future<void> write(Look look);
|
||||
}
|
||||
|
||||
/// Keeps it as JSON in [_file], in the app's support folder: it is the
|
||||
/// device's, not a person's, and is read before anyone signs in. Written to
|
||||
/// a temporary file first and renamed into place, so a crash halfway leaves
|
||||
/// it as it was. What can't be read, a damaged file or a value this version
|
||||
/// doesn't know, falls back to what the device does.
|
||||
class FileLookStore(final File _file) implements LookStore {
|
||||
// One write at a time: they share the temporary file.
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<Look> read() async {
|
||||
try {
|
||||
final json = jsonDecode(await _file.readAsString());
|
||||
if (json is! Map<String, Object?>) {
|
||||
return const Look();
|
||||
}
|
||||
return Look(
|
||||
appearance:
|
||||
Appearance.values.asNameMap()[json['appearance']] ??
|
||||
Appearance.device,
|
||||
language: switch (json['language']) {
|
||||
final String tag => tag,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
} on FileSystemException {
|
||||
return const Look();
|
||||
} on FormatException catch (error) {
|
||||
log('The look is unreadable', name: 'Look', error: error);
|
||||
return const Look();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Look look) => _queue.run(() async {
|
||||
await _file.parent.create(recursive: true);
|
||||
final temporary = File('${_file.path}.new');
|
||||
await temporary.writeAsString(
|
||||
jsonEncode({
|
||||
'appearance': look.appearance.name,
|
||||
'language': ?look.language,
|
||||
}),
|
||||
flush: true,
|
||||
);
|
||||
await temporary.rename(_file.path);
|
||||
});
|
||||
}
|
||||
65
packages/halkyon_app/lib/src/look/model/look.dart
Normal file
65
packages/halkyon_app/lib/src/look/model/look.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_app/src/app/app_frame.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// How the app looks: as the device does, or always light or dark.
|
||||
enum Appearance() {
|
||||
device,
|
||||
light,
|
||||
dark,
|
||||
}
|
||||
|
||||
/// A language a person can choose for the app: its BCP 47 `tag`, as the
|
||||
/// device keeps it (`en-GB`, `de`), and its `name` 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.
|
||||
typedef AppLanguage = ({String tag, String name});
|
||||
|
||||
/// The device's look of the app (Halkyon Learn's since 2026-10-07, every
|
||||
/// app's since ADR 0014's amendment of 4 Nov 2026): its appearance and the
|
||||
/// language it speaks. It stays on the device, follows nobody to another,
|
||||
/// and applies before anyone is signed in.
|
||||
final class const Look({
|
||||
/// Light, dark, or as the device.
|
||||
final Appearance appearance = Appearance.device,
|
||||
|
||||
/// The language's BCP 47 tag, as `en-GB` or `de`; null for the device's.
|
||||
final String? language,
|
||||
}) with Equatable {
|
||||
/// This look with [appearance] instead.
|
||||
Look withAppearance(Appearance appearance) =>
|
||||
Look(appearance: appearance, language: language);
|
||||
|
||||
/// This look speaking [language] instead; null for the device's.
|
||||
Look withLanguage(String? language) =>
|
||||
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,
|
||||
};
|
||||
|
||||
/// The app's locale; null for the device's.
|
||||
Locale? get locale => switch (language) {
|
||||
final language? => localeOfTag(language),
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// What this package's own screens follow of it while no person's app
|
||||
/// shows.
|
||||
LookSettings get settings => (themeMode: themeMode, locale: locale);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [appearance, language];
|
||||
}
|
||||
|
||||
/// The locale of the BCP 47 [tag], as `en-GB`: its language and, where it
|
||||
/// has one, its region.
|
||||
Locale localeOfTag(String tag) => switch (tag.split('-')) {
|
||||
[final language, final region, ...] => Locale(language, region),
|
||||
[final language] => Locale(language),
|
||||
// `split` always gives at least one part.
|
||||
[] => const Locale('en', 'GB'),
|
||||
};
|
||||
134
packages/halkyon_app/lib/src/look/widget/look_settings_card.dart
Normal file
134
packages/halkyon_app/lib/src/look/widget/look_settings_card.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||
import 'package:halkyon_app/src/look/bloc/look_cubit.dart';
|
||||
import 'package:halkyon_app/src/look/model/look.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The device's look of the app in Settings, on the [LookCubit] above:
|
||||
/// [LookSettingsCard] offering [languages].
|
||||
class const DeviceLookCard({
|
||||
/// The languages the app speaks, in the order the picker lists them.
|
||||
required final List<AppLanguage> languages,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.watch<LookCubit>();
|
||||
return LookSettingsCard(
|
||||
look: cubit.state,
|
||||
languages: languages,
|
||||
onAppearanceChosen: cubit.chooseAppearance,
|
||||
onLanguageChosen: cubit.chooseLanguage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How this device shows the app, [look]: its appearance, and its language,
|
||||
/// the device's or one of [languages], side by side where there is room.
|
||||
/// Both stay on the device. An app keeping them elsewhere (Halkyon Learn,
|
||||
/// among its device's settings) gives them here; the others take
|
||||
/// [DeviceLookCard].
|
||||
class const LookSettingsCard({
|
||||
/// The look chosen.
|
||||
required final Look look,
|
||||
|
||||
/// The languages the app speaks, in the order the picker lists them.
|
||||
required final List<AppLanguage> languages,
|
||||
|
||||
/// The person chose an appearance.
|
||||
required final ValueChanged<Appearance> onAppearanceChosen,
|
||||
|
||||
/// The person chose a language: its tag, or null for the device's.
|
||||
required final ValueChanged<String?> onLanguageChosen,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final appearance = _Field(
|
||||
label: l10n.lookAppearance,
|
||||
field: DropdownButtonFormField<Appearance>(
|
||||
initialValue: look.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.lookAppearanceDevice,
|
||||
Appearance.light => l10n.lookAppearanceLight,
|
||||
Appearance.dark => l10n.lookAppearanceDark,
|
||||
}),
|
||||
),
|
||||
],
|
||||
onChanged: (appearance) {
|
||||
if (appearance != null) {
|
||||
onAppearanceChosen(appearance);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
final language = _Field(
|
||||
label: l10n.lookLanguage,
|
||||
field: DropdownButtonFormField<String?>(
|
||||
// A language this version no longer speaks shows as the device's.
|
||||
initialValue: languages.any((language) => language.tag == look.language)
|
||||
? look.language
|
||||
: null,
|
||||
decoration: _decoration,
|
||||
icon: const Icon(LucideIcons.chevronDown),
|
||||
items: [
|
||||
DropdownMenuItem(child: Text(l10n.lookLanguageDevice)),
|
||||
for (final (:tag, :name) in languages)
|
||||
DropdownMenuItem(value: tag, child: Text(name)),
|
||||
],
|
||||
onChanged: onLanguageChosen,
|
||||
),
|
||||
);
|
||||
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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
18
packages/halkyon_app/lib/src/testing/memory_look_store.dart
Normal file
18
packages/halkyon_app/lib/src/testing/memory_look_store.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:halkyon_app/src/look/data/look_store.dart';
|
||||
import 'package:halkyon_app/src/look/model/look.dart';
|
||||
|
||||
/// Keeps the device's look in memory, [look] at first, and every write in
|
||||
/// [writes].
|
||||
class MemoryLookStore([var Look look = const Look()]) implements LookStore {
|
||||
/// What was written, in order.
|
||||
final writes = <Look>[];
|
||||
|
||||
@override
|
||||
Future<Look> read() async => look;
|
||||
|
||||
@override
|
||||
Future<void> write(Look look) async {
|
||||
this.look = look;
|
||||
writes.add(look);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ export 'src/testing/memory_account_key_store.dart';
|
|||
export 'src/testing/memory_account_wait_store.dart';
|
||||
export 'src/testing/memory_file_saver.dart';
|
||||
export 'src/testing/memory_instance_store.dart';
|
||||
export 'src/testing/memory_look_store.dart';
|
||||
export 'src/testing/memory_pending_changes_store.dart';
|
||||
export 'src/testing/memory_sign_in_addresses.dart';
|
||||
export 'src/testing/memory_whats_new.dart';
|
||||
|
|
|
|||
134
packages/halkyon_app/test/look/look_settings_card_test.dart
Normal file
134
packages/halkyon_app/test/look/look_settings_card_test.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
const _languages = <AppLanguage>[
|
||||
(tag: 'en-GB', name: 'English (UK)'),
|
||||
(tag: 'en-US', name: 'English (US)'),
|
||||
(tag: 'de', name: 'Deutsch'),
|
||||
];
|
||||
|
||||
void main() {
|
||||
/// The card in an app following the look it chooses, as every Halkyon
|
||||
/// app's does, on [store].
|
||||
Future<void> show(WidgetTester tester, MemoryLookStore store) async {
|
||||
final cubit = await LookCubit.open(store);
|
||||
addTearDown(cubit.close);
|
||||
await tester.pumpWidget(
|
||||
BlocProvider.value(
|
||||
value: cubit,
|
||||
child: BlocBuilder<LookCubit, Look>(
|
||||
builder: (context, look) => MaterialApp(
|
||||
theme: appTheme(Brightness.light),
|
||||
darkTheme: appTheme(Brightness.dark),
|
||||
themeMode: look.themeMode,
|
||||
locale: look.locale,
|
||||
localeListResolutionCallback: resolveAppLocale,
|
||||
localizationsDelegates: testLocalizationsDelegates,
|
||||
supportedLocales: HalkyonAppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: DeviceLookCard(languages: _languages)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Brightness brightness(WidgetTester tester) =>
|
||||
Theme.of(tester.element(find.byType(DeviceLookCard))).brightness;
|
||||
|
||||
/// Opens the menu showing [current] and picks [choice].
|
||||
Future<void> choose(
|
||||
WidgetTester tester,
|
||||
String current,
|
||||
String choice,
|
||||
) async {
|
||||
await tester.tap(find.text(current));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(choice).last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized()
|
||||
.platformDispatcher
|
||||
.platformBrightnessTestValue =
|
||||
Brightness.light;
|
||||
});
|
||||
tearDown(
|
||||
() => TestWidgetsFlutterBinding.instance.platformDispatcher
|
||||
.clearPlatformBrightnessTestValue(),
|
||||
);
|
||||
|
||||
testWidgets("the device's appearance and language first", (tester) async {
|
||||
await show(tester, MemoryLookStore());
|
||||
|
||||
expect(find.text('Appearance'), findsOneWidget);
|
||||
expect(find.text('Device theme'), findsOneWidget);
|
||||
expect(find.text('Language'), findsOneWidget);
|
||||
expect(find.text('Device language'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Dark makes the app dark, and is kept', (tester) async {
|
||||
final store = MemoryLookStore();
|
||||
await show(tester, store);
|
||||
expect(brightness(tester), Brightness.light);
|
||||
|
||||
await choose(tester, 'Device theme', 'Dark');
|
||||
|
||||
expect(brightness(tester), Brightness.dark);
|
||||
expect(store.writes.last, const Look(appearance: Appearance.dark));
|
||||
});
|
||||
|
||||
testWidgets('the languages offered, each in its own name, after the '
|
||||
"device's", (tester) async {
|
||||
await show(tester, MemoryLookStore());
|
||||
|
||||
await tester.tap(find.text('Device language'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester
|
||||
.widgetList<DropdownMenuItem<String?>>(
|
||||
find.byWidgetPredicate(
|
||||
(widget) => widget is DropdownMenuItem<String?>,
|
||||
),
|
||||
)
|
||||
.map((item) => item.value)
|
||||
.toSet()
|
||||
.toList(),
|
||||
[null, 'en-GB', 'en-US', 'de'],
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Deutsch switches the texts to German, the device language '
|
||||
'back', (tester) async {
|
||||
final store = MemoryLookStore();
|
||||
await show(tester, store);
|
||||
|
||||
await choose(tester, 'Device language', 'Deutsch');
|
||||
|
||||
expect(find.text('Darstellung'), findsOneWidget);
|
||||
expect(find.text('Gerätedesign'), findsOneWidget);
|
||||
expect(store.writes.last, const Look(language: 'de'));
|
||||
|
||||
await choose(tester, 'Deutsch', 'Gerätesprache');
|
||||
|
||||
expect(find.text('Appearance'), findsOneWidget);
|
||||
expect(store.writes.last, const Look());
|
||||
});
|
||||
|
||||
testWidgets('starts at the look kept; a language this version no longer '
|
||||
"speaks shows as the device's", (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
MemoryLookStore(const Look(appearance: Appearance.dark, language: 'fr')),
|
||||
);
|
||||
|
||||
expect(brightness(tester), Brightness.dark);
|
||||
expect(find.text('Dark'), findsOneWidget);
|
||||
expect(find.text('Device language'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
76
packages/halkyon_app/test/look/look_store_test.dart
Normal file
76
packages/halkyon_app/test/look/look_store_test.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late File file;
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('look');
|
||||
file = File('${folder.path}/look.json');
|
||||
});
|
||||
tearDown(() => folder.deleteSync(recursive: true));
|
||||
|
||||
test("the device's look before anything is kept", () async {
|
||||
expect(await FileLookStore(file).read(), const Look());
|
||||
});
|
||||
|
||||
test('keeps the appearance and language for the next start', () async {
|
||||
const look = Look(appearance: Appearance.dark, language: 'de');
|
||||
await FileLookStore(file).write(look);
|
||||
|
||||
expect(await FileLookStore(file).read(), look);
|
||||
|
||||
await FileLookStore(file).write(const Look(appearance: Appearance.light));
|
||||
expect(
|
||||
await FileLookStore(file).read(),
|
||||
const Look(appearance: Appearance.light),
|
||||
);
|
||||
});
|
||||
|
||||
test("a damaged file or an unknown value reads as the device's", () async {
|
||||
file.writeAsStringSync('{"appearance": "dark", ');
|
||||
expect(await FileLookStore(file).read(), const Look());
|
||||
|
||||
file.writeAsStringSync('[1, 2]');
|
||||
expect(await FileLookStore(file).read(), const Look());
|
||||
|
||||
file.writeAsStringSync('{"appearance": "sepia", "language": 7}');
|
||||
expect(await FileLookStore(file).read(), const Look());
|
||||
});
|
||||
|
||||
test('a look means a theme mode and a locale', () {
|
||||
expect(const Look().themeMode, ThemeMode.system);
|
||||
expect(const Look().locale, isNull);
|
||||
expect(const Look(appearance: Appearance.dark).themeMode, ThemeMode.dark);
|
||||
expect(const Look(appearance: Appearance.light).themeMode, ThemeMode.light);
|
||||
expect(const Look(language: 'de').locale, const Locale('de'));
|
||||
expect(const Look(language: 'en-US').locale, const Locale('en', 'US'));
|
||||
expect(
|
||||
const Look(appearance: Appearance.dark, language: 'de').settings,
|
||||
(themeMode: ThemeMode.dark, locale: const Locale('de')),
|
||||
);
|
||||
});
|
||||
|
||||
test('a choice applies at once and is kept', () async {
|
||||
final store = MemoryLookStore();
|
||||
final cubit = await LookCubit.open(store);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await cubit.chooseAppearance(Appearance.dark);
|
||||
await cubit.chooseLanguage('de');
|
||||
|
||||
expect(
|
||||
cubit.state,
|
||||
const Look(appearance: Appearance.dark, language: 'de'),
|
||||
);
|
||||
expect(store.look, cubit.state);
|
||||
|
||||
await cubit.chooseLanguage(null);
|
||||
expect(store.look, const Look(appearance: Appearance.dark));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue