diff --git a/apps/app_seeds/lib/ui/settings_screen.dart b/apps/app_seeds/lib/ui/settings_screen.dart index 6a5ba16..7fa3586 100644 --- a/apps/app_seeds/lib/ui/settings_screen.dart +++ b/apps/app_seeds/lib/ui/settings_screen.dart @@ -20,51 +20,12 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { final t = context.t; - // Compare the full locale (not just the language code) so `es` and the - // Asturian `es-AST` — which share the `es` language — are told apart. - final current = TranslationProvider.of(context).locale; - void pick(AppLocale locale) => - (localeStore ?? getIt()).setLocale(locale); return Scaffold( appBar: AppBar(title: Text(t.menu.settings)), body: ListView( children: [ _SectionHeader(t.settings.language), - _LanguageTile( - label: t.settings.langEs, - selected: current == AppLocale.es, - onTap: () => pick(AppLocale.es), - ), - _LanguageTile( - label: t.settings.langAst, - selected: current == AppLocale.ast, - onTap: () => pick(AppLocale.ast), - ), - _LanguageTile( - label: t.settings.langEn, - selected: current == AppLocale.en, - onTap: () => pick(AppLocale.en), - ), - _LanguageTile( - label: t.settings.langPt, - selected: current == AppLocale.pt, - onTap: () => pick(AppLocale.pt), - ), - _LanguageTile( - label: t.settings.langFr, - selected: current == AppLocale.fr, - onTap: () => pick(AppLocale.fr), - ), - _LanguageTile( - label: t.settings.langDe, - selected: current == AppLocale.de, - onTap: () => pick(AppLocale.de), - ), - ListTile( - leading: const Icon(Icons.smartphone_outlined), - title: Text(t.settings.systemLanguage), - onTap: () => (localeStore ?? getIt()).useDeviceLocale(), - ), + _LanguageSelector(localeStore: localeStore), const Divider(), _SectionHeader(t.backup.title), BackupSection(service: exportImport), @@ -89,6 +50,136 @@ class SettingsScreen extends StatelessWidget { } } +/// The full ordered list of app languages, paired with their endonym label. +/// One source of truth for both the current-choice subtitle and the picker. +List<(AppLocale, String)> _languages(Translations t) => [ + (AppLocale.es, t.settings.langEs), + (AppLocale.ast, t.settings.langAst), + (AppLocale.en, t.settings.langEn), + (AppLocale.pt, t.settings.langPt), + (AppLocale.fr, t.settings.langFr), + (AppLocale.de, t.settings.langDe), +]; + +/// A single tile showing the active language; tapping opens a picker with all +/// languages plus "System language" (follow the device). A `null` saved value +/// means the user follows the device language — the app still resolves to some +/// concrete locale, but the picker must show "System language" as the choice. +class _LanguageSelector extends StatefulWidget { + const _LanguageSelector({this.localeStore}); + + final LocaleStore? localeStore; + + @override + State<_LanguageSelector> createState() => _LanguageSelectorState(); +} + +class _LanguageSelectorState extends State<_LanguageSelector> { + /// Prefer the injected store; otherwise fall back to the locator, but only if + /// it actually has one registered — widget tests may pump this screen with no + /// DI set up, and reading the current language must not crash there. + LocaleStore? get _store => + widget.localeStore ?? + (getIt.isRegistered() ? getIt() : null); + + /// The user's explicit choice, or null when following the device language. + AppLocale? _saved; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _store?.saved().then((locale) { + if (!mounted) return; + setState(() { + _saved = locale; + _loaded = true; + }); + }); + } + + Future _select(AppLocale? locale) async { + final store = _store; + if (store == null) return; + if (locale == null) { + await store.useDeviceLocale(); + } else { + await store.setLocale(locale); + } + if (mounted) setState(() => _saved = locale); + } + + @override + Widget build(BuildContext context) { + final t = context.t; + // The tile shows the current language by its endonym. When following the + // device language, the title is still the resolved language and a subtitle + // makes the "System language" mode explicit. + final resolved = TranslationProvider.of(context).locale; + final isDevice = _loaded && _saved == null; + final label = _labelFor(t, _loaded && _saved != null ? _saved! : resolved); + return ListTile( + key: const Key('settings.language'), + leading: const Icon(Icons.translate, color: seedGreen), + title: Text(label), + subtitle: isDevice ? Text(t.settings.systemLanguage) : null, + trailing: const Icon(Icons.chevron_right), + onTap: () => _openPicker(context), + ); + } + + String _labelFor(Translations t, AppLocale locale) { + for (final (l, label) in _languages(t)) { + if (l == locale) return label; + } + return locale.languageCode; + } + + Future _openPicker(BuildContext context) async { + final t = context.t; + final selection = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) { + return SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final (locale, label) in _languages(t)) + ListTile( + title: Text(label), + trailing: _saved == locale + ? const Icon(Icons.check, color: seedGreen) + : null, + onTap: () => Navigator.pop(context, locale), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.smartphone_outlined), + title: Text(t.settings.systemLanguage), + trailing: _saved == null + ? const Icon(Icons.check, color: seedGreen) + : null, + // Use a sentinel so "System language" (null) is distinct from + // "nothing tapped" (also null from a dismissed sheet). + onTap: () => Navigator.pop(context, _deviceSentinel), + ), + ], + ), + ); + }, + ); + if (selection == null) return; // dismissed without choosing + await _select( + identical(selection, _deviceSentinel) ? null : selection as AppLocale, + ); + } +} + +/// Distinguishes an explicit "System language" pick from a dismissed sheet, +/// since both would otherwise arrive as `null`. +const Object _deviceSentinel = Object(); + class _SectionHeader extends StatelessWidget { const _SectionHeader(this.text); @@ -109,24 +200,3 @@ class _SectionHeader extends StatelessWidget { ); } } - -class _LanguageTile extends StatelessWidget { - const _LanguageTile({ - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(label), - trailing: selected ? const Icon(Icons.check, color: seedGreen) : null, - onTap: onTap, - ); - } -} diff --git a/apps/app_seeds/test/ui/asturian_locale_test.dart b/apps/app_seeds/test/ui/asturian_locale_test.dart index ec7e404..2f0fe07 100644 --- a/apps/app_seeds/test/ui/asturian_locale_test.dart +++ b/apps/app_seeds/test/ui/asturian_locale_test.dart @@ -52,6 +52,9 @@ void main() { expect(find.text('Language'), findsOneWidget); expect(materialLocale.languageCode, 'en'); + // Open the language picker, then choose Asturian. + await tester.tap(find.byKey(const Key('settings.language'))); + await tester.pumpAndSettle(); await tester.tap(find.text('Asturianu')); await tester.pumpAndSettle(); diff --git a/apps/app_seeds/test/ui/rtl_smoke_test.dart b/apps/app_seeds/test/ui/rtl_smoke_test.dart index 06d636e..0fcefe9 100644 --- a/apps/app_seeds/test/ui/rtl_smoke_test.dart +++ b/apps/app_seeds/test/ui/rtl_smoke_test.dart @@ -93,6 +93,9 @@ void main() { ); await tester.pumpAndSettle(); + // The language selector opens a picker listing every language. + await tester.tap(find.byKey(const Key('settings.language'))); + await tester.pumpAndSettle(); expect(find.text('Português'), findsOneWidget); await disposeTree(tester); });