feat(mobile): in-app locale switching
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -43,9 +43,30 @@ class _PhotoLiveAppState extends ConsumerState<PhotoLiveApp> {
|
||||
};
|
||||
}
|
||||
|
||||
/// 语言解析(P3 Task 10):
|
||||
/// - 显式选择(locale 非 null)优先,按 languageCode 匹配;
|
||||
/// - system 档位下用系统 locale 匹配(zh_* → zh、en_* → en);
|
||||
/// - 均不匹配(如 fr)回退模板语言 zh——不依赖生成代码 supportedLocales
|
||||
/// 的排序(gen-l10n 按字母序 en 在前,默认解析会回退 en,与模板不符)。
|
||||
static Locale _resolveLocale(
|
||||
Locale? deviceLocale,
|
||||
Iterable<Locale> supportedLocales,
|
||||
) {
|
||||
Locale? requested;
|
||||
for (final supported in supportedLocales) {
|
||||
if (supported.languageCode == deviceLocale?.languageCode) {
|
||||
requested = supported;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 语言码不匹配 → 模板语言(l10n.yaml template-arb-file=app_zh.arb)。
|
||||
return requested ?? const Locale('zh');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final locale = ref.watch(localeSettingsProvider);
|
||||
// select 只取 Locale?:未引起 locale 变化的状态更新不重建 MaterialApp。
|
||||
final locale = ref.watch(localeSettingsProvider.select((c) => c.locale));
|
||||
// 引擎生命周期绑定(常驻 watch;账号在场 start / 离场 stop)。
|
||||
ref.watch(uploadEngineScopeProvider);
|
||||
return MaterialApp(
|
||||
@@ -54,6 +75,7 @@ class _PhotoLiveAppState extends ConsumerState<PhotoLiveApp> {
|
||||
theme: AppTheme.light,
|
||||
darkTheme: AppTheme.dark,
|
||||
locale: locale,
|
||||
localeResolutionCallback: _resolveLocale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
routes: {
|
||||
|
||||
@@ -1,16 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// App 语言设置(脚手架)。
|
||||
/// App 语言设置(P3 计划 Task 10 完整版)。
|
||||
///
|
||||
/// `null` = 跟随系统;Task 10 补齐:持久化(settings 存储)、
|
||||
/// 设置页交互与全部页面的 l10n 覆盖检查。
|
||||
class LocaleSettings extends Notifier<Locale?> {
|
||||
@override
|
||||
Locale? build() => null;
|
||||
/// 三档选择(跟随系统 / 中文 / English),持久化于 shared_preferences
|
||||
/// 键 `app.locale`('system'|'zh'|'en')。main() 在 runApp 前读持久值
|
||||
/// 注入 Notifier 初始状态——首帧即正确语言(无 system→选择闪变);
|
||||
/// 持久值非法(旧版本/手改损坏)回退 system。
|
||||
///
|
||||
/// 存储读写失败均不阻断:读失败按 system;写失败保留本次会话状态
|
||||
/// (仅丢失记忆,下次启动回退旧值)。
|
||||
library;
|
||||
|
||||
void setLocale(Locale? locale) => state = locale;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 语言档位(枚举值顺序即设置页选项顺序)。
|
||||
enum AppLocaleChoice {
|
||||
/// 跟随系统(MaterialApp.locale=null,交由 localeResolutionCallback 解析)。
|
||||
system('system'),
|
||||
|
||||
/// 固定中文。
|
||||
zh('zh'),
|
||||
|
||||
/// 固定英文。
|
||||
en('en');
|
||||
|
||||
const AppLocaleChoice(this.persistedValue);
|
||||
|
||||
/// 持久化字符串(shared_preferences `app.locale` 的值域)。
|
||||
final String persistedValue;
|
||||
|
||||
/// 对应 [MaterialApp.locale];system 为 null。
|
||||
Locale? get locale => switch (this) {
|
||||
AppLocaleChoice.system => null,
|
||||
AppLocaleChoice.zh => const Locale('zh'),
|
||||
AppLocaleChoice.en => const Locale('en'),
|
||||
};
|
||||
|
||||
/// 持久值解析;null/未知值回退 [system](前向兼容未来新增档位)。
|
||||
static AppLocaleChoice fromPersisted(String? value) =>
|
||||
AppLocaleChoice.values.firstWhere(
|
||||
(choice) => choice.persistedValue == value,
|
||||
orElse: () => AppLocaleChoice.system,
|
||||
);
|
||||
}
|
||||
|
||||
/// 存储抽象(测试注入内存实现;生产=shared_preferences 单键)。
|
||||
abstract class LocalePrefsStore {
|
||||
Future<String?> read();
|
||||
Future<void> write(String value);
|
||||
}
|
||||
|
||||
/// [LocalePrefsStore] 的 shared_preferences 生产实现。
|
||||
class SharedPrefsLocaleStore implements LocalePrefsStore {
|
||||
const SharedPrefsLocaleStore();
|
||||
|
||||
static const String kKey = 'app.locale';
|
||||
|
||||
@override
|
||||
Future<String?> read() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(kKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(kKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 语言设置 Notifier:状态为当前档位,[localeSettingsProvider] 全局唯一。
|
||||
///
|
||||
/// 生产经 main() 以持久值构造(overrideWith 注入,见 main.dart);
|
||||
/// 测试可注入初始档位与内存存储。
|
||||
class LocaleSettings extends Notifier<AppLocaleChoice> {
|
||||
LocaleSettings({
|
||||
this.initial = AppLocaleChoice.system,
|
||||
LocalePrefsStore? store,
|
||||
}) : _store = store ?? const SharedPrefsLocaleStore();
|
||||
|
||||
/// 启动注入的初始档位(main() 以持久值构造)。
|
||||
final AppLocaleChoice initial;
|
||||
final LocalePrefsStore _store;
|
||||
|
||||
@override
|
||||
AppLocaleChoice build() => initial;
|
||||
|
||||
/// 切换档位:状态立即生效(MaterialApp 即刻重建);持久化尽力而为。
|
||||
Future<void> setChoice(AppLocaleChoice choice) async {
|
||||
state = choice;
|
||||
try {
|
||||
await _store.write(choice.persistedValue);
|
||||
} on Object {
|
||||
// 写失败不回滚:语言记忆属锦上添花,不值得打断交互。
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动读取持久档位(main() runApp 前调用;失败按 system)。
|
||||
static Future<AppLocaleChoice> readPersisted([
|
||||
LocalePrefsStore store = const SharedPrefsLocaleStore(),
|
||||
]) async {
|
||||
try {
|
||||
return AppLocaleChoice.fromPersisted(await store.read());
|
||||
} on Object {
|
||||
return AppLocaleChoice.system;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final localeSettingsProvider =
|
||||
NotifierProvider<LocaleSettings, Locale?>(LocaleSettings.new);
|
||||
NotifierProvider<LocaleSettings, AppLocaleChoice>(LocaleSettings.new);
|
||||
|
||||
@@ -2,7 +2,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'app/locale_controller.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ProviderScope(child: PhotoLiveApp()));
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 启动先读语言偏好再 runApp:首帧即正确语言(无「system 闪变」,
|
||||
// P3 Task 10)。读取失败由 readPersisted 内部兜底为 system。
|
||||
final localeChoice = await LocaleSettings.readPersisted();
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
localeSettingsProvider.overrideWith(
|
||||
() => LocaleSettings(initial: localeChoice),
|
||||
),
|
||||
],
|
||||
child: const PhotoLiveApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../app/locale_controller.dart';
|
||||
import '../core/api_client.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -56,10 +57,53 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10 补语言切换持久化)。
|
||||
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10:语言切换)。
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
/// 档位 → 展示名(语言名固定以自身语言书写,不随当前界面语言变化)。
|
||||
static String _choiceLabel(AppLocalizations l10n, AppLocaleChoice choice) =>
|
||||
switch (choice) {
|
||||
AppLocaleChoice.system => l10n.localeFollowSystem,
|
||||
AppLocaleChoice.zh => l10n.localeChinese,
|
||||
AppLocaleChoice.en => l10n.localeEnglish,
|
||||
};
|
||||
|
||||
Future<void> _pickLocale(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppLocaleChoice current,
|
||||
) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final l10n = AppLocalizations.of(sheetContext);
|
||||
// 打开时刻的快照即可:选中即 pop,无滞留期间的陈旧选中态。
|
||||
return SafeArea(
|
||||
child: RadioGroup<AppLocaleChoice>(
|
||||
groupValue: current,
|
||||
onChanged: (choice) {
|
||||
if (choice == null) return;
|
||||
ref.read(localeSettingsProvider.notifier).setChoice(choice);
|
||||
Navigator.of(sheetContext).pop();
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final choice in AppLocaleChoice.values)
|
||||
RadioListTile<AppLocaleChoice>(
|
||||
value: choice,
|
||||
title: Text(_choiceLabel(l10n, choice)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
||||
// api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。
|
||||
await ref.read(apiClientProvider).logout();
|
||||
@@ -72,6 +116,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final choice = ref.watch(localeSettingsProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.settings)),
|
||||
body: ListView(
|
||||
@@ -79,7 +124,8 @@ class SettingsTab extends ConsumerWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(l10n.language),
|
||||
subtitle: Text(l10n.localeFollowSystem),
|
||||
subtitle: Text(_choiceLabel(l10n, choice)),
|
||||
onTap: () => _pickLocale(context, ref, choice),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.password_outlined),
|
||||
|
||||
Reference in New Issue
Block a user