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.
202 lines
6.7 KiB
Dart
202 lines
6.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../di/injector.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../services/export_import_service.dart';
|
|
import '../services/locale_store.dart';
|
|
import 'backup_section.dart';
|
|
import 'theme.dart';
|
|
|
|
/// Basic settings: app language, backup (export/import) and an "about"
|
|
/// section. [exportImport] and [localeStore] are injectable so widget tests can
|
|
/// pass fakes; the app resolves them lazily from the service locator.
|
|
class SettingsScreen extends StatelessWidget {
|
|
const SettingsScreen({this.exportImport, this.localeStore, super.key});
|
|
|
|
final ExportImportService? exportImport;
|
|
final LocaleStore? localeStore;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(t.menu.settings)),
|
|
body: ListView(
|
|
children: [
|
|
_SectionHeader(t.settings.language),
|
|
_LanguageSelector(localeStore: localeStore),
|
|
const Divider(),
|
|
_SectionHeader(t.backup.title),
|
|
BackupSection(service: exportImport),
|
|
const Divider(),
|
|
_SectionHeader(t.settings.about),
|
|
ListTile(
|
|
leading: Image.asset('assets/logo.png', width: 32),
|
|
title: Text(t.settings.aboutOpen),
|
|
subtitle: Text(t.settings.aboutText),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => context.push('/about'),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.privacy_tip_outlined, color: seedGreen),
|
|
title: Text(t.legal.title),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => context.push('/legal'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
|
|
final String text;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
|
child: Text(
|
|
text,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: seedGreen,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|