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),
+172
View File
@@ -0,0 +1,172 @@
/// App 内语言切换测试(P3 计划 Task 10)。
///
/// 覆盖:
/// - 启动:持久档位注入 Notifier → 首帧即对应语言(无闪变);
/// - 切换:setChoice → MaterialApp 即刻重建(登录页文案随切)+ 写持久层;
/// - 解析矩阵(system 档):系统 zh → zh、系统 en → en、系统其他 →
/// 回退模板语言 zh(l10n.yaml template=app_zh.arb);
/// - 显式选择优先于系统 locale;
/// - 持久值解析容错(null/非法值/存储异常 → system)。
library;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/app/app.dart';
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import '../helpers/fakes.dart';
/// 写入即抛的存储替身(持久化失败容错断言用)。
class ThrowingWriteLocaleStore extends FakeLocaleStore {
@override
Future<void> write(String value) async => throw StateError('disk full');
}
void main() {
group('持久值解析(纯逻辑)', () {
test('fromPersisted:合法值/空值/非法值', () {
expect(AppLocaleChoice.fromPersisted('system'), AppLocaleChoice.system);
expect(AppLocaleChoice.fromPersisted('zh'), AppLocaleChoice.zh);
expect(AppLocaleChoice.fromPersisted('en'), AppLocaleChoice.en);
expect(AppLocaleChoice.fromPersisted(null), AppLocaleChoice.system);
expect(AppLocaleChoice.fromPersisted('fr'), AppLocaleChoice.system,
reason: '未来档位/手改损坏的旧值一律回退 system');
});
test('readPersisted:读取异常 → system(启动不因存储故障卡死)', () async {
final store = ThrowingWriteLocaleStore();
final choice = await LocaleSettings.readPersisted(store);
expect(choice, AppLocaleChoice.system);
});
test('setChoice 写失败不回滚本次会话状态', () async {
final container = ProviderContainer(
overrides: [
localeSettingsProvider.overrideWith(
() => LocaleSettings(store: ThrowingWriteLocaleStore()),
),
],
);
addTearDown(container.dispose);
await container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.en);
expect(container.read(localeSettingsProvider), AppLocaleChoice.en);
});
});
group('PhotoLiveApp 语言接线(widget)', () {
late FakeSessionStore session;
late ApiClient client;
setUp(() {
// 无会话:App 停在登录页(文案断言载体);客户端拒绝一切请求
//(语言测试不触网,仅为满足 Provider 依赖图)。
session = FakeSessionStore();
client = ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter =
FakeAdapter((o) => throw StateError('不应发起请求: ${o.path}')),
);
});
Future<ProviderContainer> pumpApp(
WidgetTester tester, {
AppLocaleChoice initial = AppLocaleChoice.system,
FakeLocaleStore? localeStore,
}) async {
final store = localeStore ?? FakeLocaleStore();
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(session: session, client: client),
localeSettingsProvider.overrideWith(
() => LocaleSettings(initial: initial, store: store),
),
],
child: const PhotoLiveApp(),
),
);
await tester.pumpAndSettle();
return ProviderScope.containerOf(
tester.element(find.byType(PhotoLiveApp)),
);
}
testWidgets('持久档位 zh → 首帧中文文案', (tester) async {
final store = FakeLocaleStore()..value = 'zh';
await pumpApp(tester,
initial: AppLocaleChoice.zh, localeStore: store);
expect(find.text('登录'), findsOneWidget);
expect(find.text('Sign in'), findsNothing);
});
testWidgets('切换 en → 登录页即刻英文 + 持久化 en', (tester) async {
final store = FakeLocaleStore();
final container = await pumpApp(tester,
initial: AppLocaleChoice.zh, localeStore: store);
expect(find.text('登录'), findsOneWidget);
await container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.en);
await tester.pumpAndSettle();
expect(find.text('Sign in'), findsOneWidget,
reason: 'MaterialApp 已随 provider 重建');
expect(find.text('登录'), findsNothing);
expect(store.writes, ['en'], reason: '写入持久层');
});
testWidgets('切换回 system → 交还系统 locale', (tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('en')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
final container = await pumpApp(tester, initial: AppLocaleChoice.zh);
expect(find.text('登录'), findsOneWidget);
await container
.read(localeSettingsProvider.notifier)
.setChoice(AppLocaleChoice.system);
await tester.pumpAndSettle();
expect(find.text('Sign in'), findsOneWidget, reason: '系统 en 接管');
});
testWidgets('解析矩阵:system + 系统 zh → zh', (tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('zh')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester);
expect(find.text('登录'), findsOneWidget);
});
testWidgets('解析矩阵:system + 系统 en → en', (tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('en')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester);
expect(find.text('Sign in'), findsOneWidget);
});
testWidgets('解析矩阵:system + 系统其他(fr)→ 回退模板语言 zh',
(tester) async {
// Localizations 解析用 platformDispatcher.locales(首选语言列表),
// 单值 localeTestValue 不影响它——须整体注入列表。
tester.platformDispatcher.localesTestValue = const [Locale('fr')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester);
expect(find.text('登录'), findsOneWidget,
reason: '不依赖 supportedLocales 排序(默认解析会回退 en)');
});
testWidgets('显式选择优先于系统 locale(选 en + 系统 zh → en)',
(tester) async {
tester.platformDispatcher.localesTestValue = const [Locale('zh')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
await pumpApp(tester, initial: AppLocaleChoice.en);
expect(find.text('Sign in'), findsOneWidget);
});
});
}
+19
View File
@@ -10,6 +10,7 @@ import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/providers.dart';
@@ -29,6 +30,24 @@ class FakeSessionStore implements SessionStore {
Future<void> clear() async => session = null;
}
/// 内存语言偏好存储(测试替身,P3 Task 10)。
class FakeLocaleStore implements LocalePrefsStore {
/// 当前持久值(read 返回);null = 未设置(跟随系统)。
String? value;
/// 按序记录全部写入(断言持久化调用用)。
final List<String> writes = <String>[];
@override
Future<String?> read() async => value;
@override
Future<void> write(String value) async {
writes.add(value);
this.value = value;
}
}
/// Widget 测试通用 overrides(P3 Task 9 起,登录页/主壳会触达
/// sessionAccountProvider 与任务库 Provider——真实 flutter_secure_storage
/// 与 path_provider 的平台通道在测试环境挂起,凡 pump 到相关页面均需
@@ -0,0 +1,121 @@
/// arb 完整性单测(P3 计划 Task 10:漏译检查)。
///
/// zh(模板)/en 两份 arb 直接按 JSON 解析比对:
/// 1. 消息键集合相等(任一侧新增/删除键即失败——杜绝漏译);
/// 2. 每条消息的占位符 token 集合相等(en 用了 {count} 而 zh 没有即失败,
/// 反之亦然——杜绝运行时 Intl 占位符缺失/多参崩溃);
/// 3. `@key` 元数据键必须对应真实消息键,且其 placeholders 名称与
/// 消息体中提取的 token 一致(两侧一致)。
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
Map<String, Object?> _loadArb(String fileName) {
final file = File('lib/l10n/$fileName');
if (!file.existsSync()) {
fail('arb 文件缺失: ${file.path}');
}
return jsonDecode(file.readAsStringSync()) as Map<String, Object?>;
}
/// `{name}` 占位符 token 提取(ICU 复数/选择子句的内层同名 token 同样
/// 命中;`{count, plural,` 等带参数形式不误报为 token)。
Set<String> _placeholdersOf(String message) =>
RegExp(r'\{([a-zA-Z][a-zA-Z0-9_]*)\}')
.allMatches(message)
.map((m) => m.group(1)!)
.toSet();
Set<String> _messageKeys(Map<String, Object?> arb) =>
arb.keys.where((k) => !k.startsWith('@')).toSet();
void main() {
final zh = _loadArb('app_zh.arb');
final en = _loadArb('app_en.arb');
final zhKeys = _messageKeys(zh);
final enKeys = _messageKeys(en);
test('zh/en 消息键集合相等(漏译防护)', () {
final zhOnly = zhKeys.difference(enKeys).toList()..sort();
final enOnly = enKeys.difference(zhKeys).toList()..sort();
expect(zhOnly, isEmpty, reason: 'en 缺少这些键: $zhOnly');
expect(enOnly, isEmpty, reason: 'zh 缺少这些键: $enOnly');
});
test('@@locale 元数据正确', () {
expect(zh['@@locale'], 'zh');
expect(en['@@locale'], 'en');
});
test('每条消息的占位符 token 两侧一致', () {
final mismatched = <String>[];
for (final key in zhKeys.intersection(enKeys)) {
final zhValue = zh[key];
final enValue = en[key];
if (zhValue is! String || enValue is! String) continue;
final zhTokens = _placeholdersOf(zhValue);
final enTokens = _placeholdersOf(enValue);
final sameSet = zhTokens.length == enTokens.length &&
zhTokens.containsAll(enTokens);
if (!sameSet) {
mismatched.add(
'$key: zh${zhTokens.toList()} vs en${enTokens.toList()}',
);
}
}
expect(mismatched, isEmpty, reason: '占位符不一致:\n${mismatched.join('\n')}');
});
test('@key 元数据与消息键对应且 placeholders 名称一致', () {
for (final arb in [zh, en]) {
final locale = arb['@@locale'] as String?;
final messageKeys = _messageKeys(arb);
final metaKeys = arb.keys
.where((k) => k.startsWith('@') && k != '@@locale')
.toSet();
final dangling =
{for (final k in metaKeys) k.substring(1)}
.difference(messageKeys)
.toList()
..sort();
expect(
dangling,
isEmpty,
reason: '$locale 存在无对应消息键的元数据: $dangling',
);
for (final metaKey in metaKeys) {
final key = metaKey.substring(1);
final meta = arb[metaKey];
if (meta is! Map) continue;
final declared = (meta['placeholders'] as Map<Object?, Object?>?)
?.keys
.whereType<String>()
.toSet();
if (declared == null) continue;
final message = arb[key];
if (message is! String) continue;
expect(
_placeholdersOf(message),
declared,
reason: '$locale `$key` 消息体 token 与 @placeholders 声明不一致',
);
}
}
});
test('生成的 AppLocalizations 覆盖全部 arb 消息键', () {
// 拦截「改了 arb 忘了重新生成本地化代码」的漂移(flutter test 本身
// 会触发生成,此处守护绕过 flutter 工具链的手工改动)。
final generated =
File('lib/l10n/app_localizations.dart').readAsStringSync();
final missing = zhKeys
.where((key) => !RegExp('\\b$key\\b').hasMatch(generated))
.toList()
..sort();
expect(missing, isEmpty, reason: '生成代码缺少这些键: $missing');
});
}
+42 -1
View File
@@ -8,6 +8,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
@@ -35,13 +36,18 @@ void main() {
..httpClientAdapter = adapter,
);
Future<void> pumpHome(WidgetTester tester, ApiClient client) async {
Future<void> pumpHome(
WidgetTester tester,
ApiClient client, {
List<dynamic> extraOverrides = const <dynamic>[],
}) async {
await tester.pumpWidget(
ProviderScope(
// Task 9 起主壳含队列页(触达 sessionAccountProvider/TaskStore),
// 真实平台通道在测试环境挂起,必须注入替身。
overrides: [
...pageTestOverrides(session: store, client: client),
...extraOverrides,
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -92,4 +98,39 @@ void main() {
expect(find.byType(LoginPage), findsOneWidget);
expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈');
});
testWidgets('语言切换:底部弹层三选一 → 更新档位 + 持久化(P3 Task 10)',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
final localeStore = FakeLocaleStore();
await pumpHome(
tester,
clientOf(adapter),
extraOverrides: [
localeSettingsProvider.overrideWith(
() => LocaleSettings(store: localeStore),
),
],
);
// 本 harness 的 MaterialApp 固定 en(live 全 App 重建在
// app/locale_test.dart 的 PhotoLiveApp 用例覆盖)。
expect(find.text('Language'), findsOneWidget);
expect(find.text('Follow system'), findsOneWidget, reason: '默认档位副标题');
await tester.tap(find.text('Language'));
await tester.pumpAndSettle();
// 弹层三选项(语言名以自身语言书写,跨 locale 不变)。
// 「Follow system」出现两次:主档 tile 副标题 + 弹层选项。
expect(find.text('Follow system'), findsNWidgets(2));
expect(find.text('中文'), findsOneWidget);
expect(find.text('English'), findsOneWidget);
await tester.tap(find.text('中文'));
await tester.pumpAndSettle();
expect(find.text('中文'), findsOneWidget, reason: '副标题更新为当前档位');
expect(localeStore.writes, ['zh'], reason: '选择写入 app.locale');
});
}