Fill the ~74 UI strings pt.i18n.json was missing (saved searches, label scanning, Plantaré, lot history), then add pt_BR as a proper Brazilian variant rather than a copy of European Portuguese: tu/teu/tua -> você/ seu/sua with matching verb conjugation, partilhar -> compartilhar, and Guardar -> Salvar for UI actions (seed-saving keeps "guardar", the correct term in both variants). Wires AppLocale.ptBr into the language picker and regenerates slang output.
204 lines
6.8 KiB
Dart
204 lines
6.8 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.ptBr, t.settings.langPtBr),
|
|
(AppLocale.fr, t.settings.langFr),
|
|
(AppLocale.de, t.settings.langDe),
|
|
(AppLocale.ja, t.settings.langJa),
|
|
];
|
|
|
|
/// 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|