feat(mobile): in-app locale switching

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 05:00:11 +08:00
parent 955f9b13c1
commit 8f3966d7b5
8 changed files with 548 additions and 17 deletions
+23 -1
View File
@@ -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: {
+107 -11
View File
@@ -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);
+16 -2
View File
@@ -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(),
),
);
}
+48 -2
View File
@@ -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),