diff --git a/apps/mobile/lib/app/app.dart b/apps/mobile/lib/app/app.dart index 9c93910..5ebb60b 100644 --- a/apps/mobile/lib/app/app.dart +++ b/apps/mobile/lib/app/app.dart @@ -43,9 +43,30 @@ class _PhotoLiveAppState extends ConsumerState { }; } + /// 语言解析(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 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 { theme: AppTheme.light, darkTheme: AppTheme.dark, locale: locale, + localeResolutionCallback: _resolveLocale, supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, routes: { diff --git a/apps/mobile/lib/app/locale_controller.dart b/apps/mobile/lib/app/locale_controller.dart index 784009d..7669263 100644 --- a/apps/mobile/lib/app/locale_controller.dart +++ b/apps/mobile/lib/app/locale_controller.dart @@ -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 { - @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 read(); + Future write(String value); +} + +/// [LocalePrefsStore] 的 shared_preferences 生产实现。 +class SharedPrefsLocaleStore implements LocalePrefsStore { + const SharedPrefsLocaleStore(); + + static const String kKey = 'app.locale'; + + @override + Future read() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(kKey); + } + + @override + Future write(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(kKey, value); + } +} + +/// 语言设置 Notifier:状态为当前档位,[localeSettingsProvider] 全局唯一。 +/// +/// 生产经 main() 以持久值构造(overrideWith 注入,见 main.dart); +/// 测试可注入初始档位与内存存储。 +class LocaleSettings extends Notifier { + LocaleSettings({ + this.initial = AppLocaleChoice.system, + LocalePrefsStore? store, + }) : _store = store ?? const SharedPrefsLocaleStore(); + + /// 启动注入的初始档位(main() 以持久值构造)。 + final AppLocaleChoice initial; + final LocalePrefsStore _store; + + @override + AppLocaleChoice build() => initial; + + /// 切换档位:状态立即生效(MaterialApp 即刻重建);持久化尽力而为。 + Future setChoice(AppLocaleChoice choice) async { + state = choice; + try { + await _store.write(choice.persistedValue); + } on Object { + // 写失败不回滚:语言记忆属锦上添花,不值得打断交互。 + } + } + + /// 启动读取持久档位(main() runApp 前调用;失败按 system)。 + static Future readPersisted([ + LocalePrefsStore store = const SharedPrefsLocaleStore(), + ]) async { + try { + return AppLocaleChoice.fromPersisted(await store.read()); + } on Object { + return AppLocaleChoice.system; + } + } } final localeSettingsProvider = - NotifierProvider(LocaleSettings.new); + NotifierProvider(LocaleSettings.new); diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index ef4933b..7b32389 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -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 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(), + ), + ); } diff --git a/apps/mobile/lib/pages/home_page.dart b/apps/mobile/lib/pages/home_page.dart index a05c40f..90825fa 100644 --- a/apps/mobile/lib/pages/home_page.dart +++ b/apps/mobile/lib/pages/home_page.dart @@ -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 { } } -/// 设置页(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 _pickLocale( + BuildContext context, + WidgetRef ref, + AppLocaleChoice current, + ) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final l10n = AppLocalizations.of(sheetContext); + // 打开时刻的快照即可:选中即 pop,无滞留期间的陈旧选中态。 + return SafeArea( + child: RadioGroup( + 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( + value: choice, + title: Text(_choiceLabel(l10n, choice)), + ), + ], + ), + ), + ); + }, + ); + } + Future _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), diff --git a/apps/mobile/test/app/locale_test.dart b/apps/mobile/test/app/locale_test.dart new file mode 100644 index 0000000..8d233e3 --- /dev/null +++ b/apps/mobile/test/app/locale_test.dart @@ -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 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 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); + }); + }); +} diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart index 80ef64f..aee8342 100644 --- a/apps/mobile/test/helpers/fakes.dart +++ b/apps/mobile/test/helpers/fakes.dart @@ -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 clear() async => session = null; } +/// 内存语言偏好存储(测试替身,P3 Task 10)。 +class FakeLocaleStore implements LocalePrefsStore { + /// 当前持久值(read 返回);null = 未设置(跟随系统)。 + String? value; + + /// 按序记录全部写入(断言持久化调用用)。 + final List writes = []; + + @override + Future read() async => value; + + @override + Future write(String value) async { + writes.add(value); + this.value = value; + } +} + /// Widget 测试通用 overrides(P3 Task 9 起,登录页/主壳会触达 /// sessionAccountProvider 与任务库 Provider——真实 flutter_secure_storage /// 与 path_provider 的平台通道在测试环境挂起,凡 pump 到相关页面均需 diff --git a/apps/mobile/test/l10n/arb_completeness_test.dart b/apps/mobile/test/l10n/arb_completeness_test.dart new file mode 100644 index 0000000..efe4cb5 --- /dev/null +++ b/apps/mobile/test/l10n/arb_completeness_test.dart @@ -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 _loadArb(String fileName) { + final file = File('lib/l10n/$fileName'); + if (!file.existsSync()) { + fail('arb 文件缺失: ${file.path}'); + } + return jsonDecode(file.readAsStringSync()) as Map; +} + +/// `{name}` 占位符 token 提取(ICU 复数/选择子句的内层同名 token 同样 +/// 命中;`{count, plural,` 等带参数形式不误报为 token)。 +Set _placeholdersOf(String message) => + RegExp(r'\{([a-zA-Z][a-zA-Z0-9_]*)\}') + .allMatches(message) + .map((m) => m.group(1)!) + .toSet(); + +Set _messageKeys(Map 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 = []; + 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?) + ?.keys + .whereType() + .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'); + }); +} diff --git a/apps/mobile/test/pages/settings_tab_test.dart b/apps/mobile/test/pages/settings_tab_test.dart index 56b22b2..fd958fc 100644 --- a/apps/mobile/test/pages/settings_tab_test.dart +++ b/apps/mobile/test/pages/settings_tab_test.dart @@ -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 pumpHome(WidgetTester tester, ApiClient client) async { + Future pumpHome( + WidgetTester tester, + ApiClient client, { + List extraOverrides = const [], + }) 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'); + }); }