feat(settings): collapse language list into a picker

The flat list of 6 languages plus 'system' dominated Settings. Replace it
with a single tile showing the current language, opening a bottom-sheet
picker (all languages + 'System language'). Resolve LocaleStore defensively
so the screen renders in DI-less widget tests.
This commit is contained in:
vjrj 2026-07-13 17:56:23 +02:00
parent 7ef3de04d7
commit 41ea6735d2
3 changed files with 137 additions and 61 deletions

View file

@ -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<LocaleStore>()).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<LocaleStore>()).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<LocaleStore>() ? getIt<LocaleStore>() : 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<void> _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<void> _openPicker(BuildContext context) async {
final t = context.t;
final selection = await showModalBottomSheet<Object?>(
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,
);
}
}

View file

@ -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();

View file

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