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:
parent
b04d45c4b8
commit
fe4591d747
3 changed files with 137 additions and 61 deletions
|
|
@ -20,51 +20,12 @@ class SettingsScreen extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(t.menu.settings)),
|
appBar: AppBar(title: Text(t.menu.settings)),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
_SectionHeader(t.settings.language),
|
_SectionHeader(t.settings.language),
|
||||||
_LanguageTile(
|
_LanguageSelector(localeStore: localeStore),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_SectionHeader(t.backup.title),
|
_SectionHeader(t.backup.title),
|
||||||
BackupSection(service: exportImport),
|
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 {
|
class _SectionHeader extends StatelessWidget {
|
||||||
const _SectionHeader(this.text);
|
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ void main() {
|
||||||
expect(find.text('Language'), findsOneWidget);
|
expect(find.text('Language'), findsOneWidget);
|
||||||
expect(materialLocale.languageCode, 'en');
|
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.tap(find.text('Asturianu'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,9 @@ void main() {
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
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);
|
expect(find.text('Português'), findsOneWidget);
|
||||||
await disposeTree(tester);
|
await disposeTree(tester);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue