tane/apps/app_seeds/lib/ui/settings_screen.dart
vjrj 7ae6becd8f feat(i18n): full RTL/CJK support — fonts, Japanese locale, directional fixes
Bundle Noto Sans Arabic + Noto Sans JP (SIL OFL 1.1) as per-glyph fallbacks
for both the UI text theme and every generated PDF, so RTL (Arabic) and CJK
render on all platforms — including desktop, where the system font may lack
them — instead of tofu. A shared pdf_fonts.dart centralizes the PDF theme and
the three label/catalog/recovery services reuse it.

Add Japanese (ja) as the CJK reference locale — fittingly, the app's own name
is Japanese (種, tane, 'seed'). Core strings translated; the rest fall back to
English via slang. Wired into supportedLocales (automatic) and the settings
picker.

Fix three physical Alignment.centerLeft -> AlignmentDirectional.centerStart in
variety_detail_screen, and make month-abbreviation and avatar-initial
truncation grapheme-safe (.characters) so CJK text is never cut mid-character.

Tests: CJK/Arabic PDF glyph render, ja locale resolution + English fallback.
The chat-bubble mirroring flagged by the audit was verified correct (already
respects Directionality) and left unchanged.
2026-07-14 11:11:19 +02:00

203 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),
(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,
),
),
);
}
}