diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index a989a8b..00d554c 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -15,18 +15,28 @@ class AppConfig { /// `ipconfig getifaddr en0` 重新确认并改这里; /// 也可以换成 Bonjour 名字 `http://.local:8081`(本机是 /// `jiangdeMacBook-Air.local`),换网络不用改,但部分办公网会拦组播解析。 - static const String devHostBaseUrl = - 'http://jiangdeMacBook-Air.local:8081'; + static const String devHostBaseUrl = 'http://jiangdeMacBook-Air.local:8081'; static const String _loopbackBaseUrl = 'http://127.0.0.1:8081'; static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL'); - static String get apiBaseUrl => - _overrideBaseUrl.isNotEmpty ? _overrideBaseUrl : _defaultBaseUrl; + static String _runtimeBaseUrl = ''; - static final String _defaultBaseUrl = - _runsOnDevMachine ? _loopbackBaseUrl : devHostBaseUrl; + /// Build-time server URL supplied through `--dart-define=API_BASE_URL`. + static String get compiledBaseUrl => _overrideBaseUrl; + + /// Existing platform-aware fallback used when no runtime or build override exists. + static String get defaultBaseUrl => _defaultBaseUrl; + + /// Effective server URL, including the debug-only runtime override. + static String get apiBaseUrl => _runtimeBaseUrl.isNotEmpty + ? _runtimeBaseUrl + : (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl); + + static final String _defaultBaseUrl = _runsOnDevMachine + ? _loopbackBaseUrl + : devHostBaseUrl; /// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。 /// Android 没有等价的探测方式,统一按真机处理(模拟器用 @@ -39,6 +49,16 @@ class AppConfig { (defaultTargetPlatform == TargetPlatform.iOS && Platform.environment.containsKey('SIMULATOR_DEVICE_NAME')); + /// Applies an already normalized runtime URL. Passing null clears it. + static void applyRuntimeBaseUrl(String? value) { + _runtimeBaseUrl = value ?? ''; + } + + /// Clears the in-memory runtime override, mainly for tests and restarts. + static void clearRuntimeBaseUrl() { + _runtimeBaseUrl = ''; + } + static const Duration requestTimeout = Duration(seconds: 8); /// 后端返回的是相对路径(/files/...),这里补上服务地址。 diff --git a/lib/core/config/server_endpoint.dart b/lib/core/config/server_endpoint.dart new file mode 100644 index 0000000..332eb6b --- /dev/null +++ b/lib/core/config/server_endpoint.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'app_config.dart'; + +/// Debug-only runtime server endpoint persisted outside build-time defines. +abstract final class ServerEndpoint { + /// Preference key used for the runtime server address. + static const String preferenceKey = 'dev.api_base_url'; + + /// Returns a normalized http(s) URL, or null for invalid input. + static String? normalize(String? raw) { + final value = raw?.trim() ?? ''; + final uri = Uri.tryParse(value); + if (uri == null || + (uri.scheme != 'http' && uri.scheme != 'https') || + uri.host.isEmpty) { + return null; + } + return value.replaceFirst(RegExp(r'/+$'), ''); + } + + /// Resolves runtime > dart-define > default using normalized valid values. + static String resolve({ + required String? stored, + required String compiled, + required String fallback, + }) { + return normalize(stored) ?? + normalize(compiled) ?? + normalize(fallback) ?? + fallback; + } + + /// Loads the persisted override before the app creates its ApiClient. + /// + /// In release builds this returns immediately without reading preferences. + static Future load({ + bool debug = kDebugMode, + SharedPreferencesAsync? preferences, + }) async { + if (!debug) return; + try { + final prefs = preferences ?? SharedPreferencesAsync(); + final stored = await prefs.getString(preferenceKey); + AppConfig.applyRuntimeBaseUrl( + resolve( + stored: stored, + compiled: AppConfig.compiledBaseUrl, + fallback: AppConfig.defaultBaseUrl, + ), + ); + } catch (_) { + AppConfig.clearRuntimeBaseUrl(); + } + } + + /// Persists a normalized runtime endpoint. + static Future save( + String raw, { + SharedPreferencesAsync? preferences, + }) async { + final normalized = normalize(raw); + if (normalized == null) return false; + try { + await (preferences ?? SharedPreferencesAsync()).setString( + preferenceKey, + normalized, + ); + return true; + } catch (_) { + return false; + } + } + + /// Removes the persisted runtime endpoint. + static Future clear({SharedPreferencesAsync? preferences}) async { + try { + await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey); + } catch (_) { + // Clearing an optional developer override is best effort. + } + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f94a454..18ebcfb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -262,6 +262,12 @@ "freeBonus": "Get Free Bonus", "watchHistory": "Watch History", "settings": "Settings", + "serverAddress": "Server address", + "serverAddressHint": "http(s)://host:port", + "saveAddress": "Save address", + "restartToApply": "Restart the app to apply.", + "restoreDefault": "Restore default", + "invalidServerAddress": "Enter a valid http(s) server address.", "signOut": "Sign Out", "mockUserName": "Vicky sheng", "mockUserId": "ID: M1CZWVYE", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 92ea241..ce7eea5 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -262,6 +262,12 @@ "freeBonus": "Bonus Gratis", "watchHistory": "Riwayat Tontonan", "settings": "Pengaturan", + "serverAddress": "Alamat server", + "serverAddressHint": "http(s)://host:port", + "saveAddress": "Simpan alamat", + "restartToApply": "Mulai ulang aplikasi untuk menerapkan.", + "restoreDefault": "Pulihkan default", + "invalidServerAddress": "Masukkan alamat server http(s) yang valid.", "signOut": "Keluar", "mockUserName": "Vicky sheng", "mockUserId": "ID: M1CZWVYE", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4c04af7..aeed5a1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1228,6 +1228,42 @@ abstract class AppLocalizations { /// **'Settings'** String get settings; + /// No description provided for @serverAddress. + /// + /// In en, this message translates to: + /// **'Server address'** + String get serverAddress; + + /// No description provided for @serverAddressHint. + /// + /// In en, this message translates to: + /// **'http(s)://host:port'** + String get serverAddressHint; + + /// No description provided for @saveAddress. + /// + /// In en, this message translates to: + /// **'Save address'** + String get saveAddress; + + /// No description provided for @restartToApply. + /// + /// In en, this message translates to: + /// **'Restart the app to apply.'** + String get restartToApply; + + /// No description provided for @restoreDefault. + /// + /// In en, this message translates to: + /// **'Restore default'** + String get restoreDefault; + + /// No description provided for @invalidServerAddress. + /// + /// In en, this message translates to: + /// **'Enter a valid http(s) server address.'** + String get invalidServerAddress; + /// No description provided for @signOut. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 39f794a..40e3466 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -601,6 +601,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings => 'Settings'; + @override + String get serverAddress => 'Server address'; + + @override + String get serverAddressHint => 'http(s)://host:port'; + + @override + String get saveAddress => 'Save address'; + + @override + String get restartToApply => 'Restart the app to apply.'; + + @override + String get restoreDefault => 'Restore default'; + + @override + String get invalidServerAddress => 'Enter a valid http(s) server address.'; + @override String get signOut => 'Sign Out'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index fa6c474..9063d41 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -603,6 +603,25 @@ class AppLocalizationsId extends AppLocalizations { @override String get settings => 'Pengaturan'; + @override + String get serverAddress => 'Alamat server'; + + @override + String get serverAddressHint => 'http(s)://host:port'; + + @override + String get saveAddress => 'Simpan alamat'; + + @override + String get restartToApply => 'Mulai ulang aplikasi untuk menerapkan.'; + + @override + String get restoreDefault => 'Pulihkan default'; + + @override + String get invalidServerAddress => + 'Masukkan alamat server http(s) yang valid.'; + @override String get signOut => 'Keluar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 23a1192..8bd6d57 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -592,6 +592,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings => '设置'; + @override + String get serverAddress => '服务器地址'; + + @override + String get serverAddressHint => 'http(s)://主机:端口'; + + @override + String get saveAddress => '保存地址'; + + @override + String get restartToApply => '重启 App 生效'; + + @override + String get restoreDefault => '恢复默认'; + + @override + String get invalidServerAddress => '请输入有效的 http(s) 服务器地址。'; + @override String get signOut => '退出登录'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c3d1daa..625b639 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -224,6 +224,12 @@ "freeBonus": "领免费奖励", "watchHistory": "观看历史", "settings": "设置", + "serverAddress": "服务器地址", + "serverAddressHint": "http(s)://主机:端口", + "saveAddress": "保存地址", + "restartToApply": "重启 App 生效", + "restoreDefault": "恢复默认", + "invalidServerAddress": "请输入有效的 http(s) 服务器地址。", "signOut": "退出登录", "mockUserName": "Vicky sheng", "mockUserId": "ID: M1CZWVYE", diff --git a/lib/main.dart b/lib/main.dart index 23b1398..16769d8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,10 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; + import 'app.dart'; +import 'core/config/server_endpoint.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + await ServerEndpoint.load(); runApp(const LumaDramaApp()); } diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index be6faf8..b2e2f9d 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -1,12 +1,16 @@ // Author: fruit.com.cn +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/config/locale_controller.dart'; +import '../../core/config/server_endpoint.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/theme/app_colors.dart'; import '../../l10n/app_localizations.dart'; enum MyPageAction { home, discover, resume } @@ -110,39 +114,178 @@ class MineLogic extends ChangeNotifier { } } - Future showLanguage(BuildContext context) async { + Future showLanguage( + BuildContext context, { + bool showServerSetting = kDebugMode, + }) async { final l10n = AppLocalizations.of(context)!; await showModalBottomSheet( context: context, backgroundColor: const Color(0xFF1C1D22), showDragHandle: true, + isScrollControlled: true, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile(title: Text(l10n.language)), - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ('id', l10n.bahasaIndonesia), - ]) - ListTile( - title: Text(label), - onTap: () { - context.read().select(code); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.85, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(l10n.language)), + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ('id', l10n.bahasaIndonesia), + ]) + ListTile( + title: Text(label), + onTap: () { + context.read().select(code); + Navigator.pop(sheetContext); + }, + ), + if (showServerSetting) ...[ + const Divider(height: 1), + ListTile( + key: const Key('server-endpoint-setting'), + leading: const Icon(Icons.dns_outlined), + title: Text(l10n.serverAddress), + subtitle: Text(AppConfig.apiBaseUrl), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _editServerEndpoint(context, l10n), + ), + ], + const SizedBox(height: 8), + ], + ), + ), ), ), ); } + Future _editServerEndpoint( + BuildContext context, + AppLocalizations l10n, + ) async { + final changed = await showDialog( + context: context, + builder: (_) => _ServerEndpointDialog(l10n: l10n), + ); + if (changed == true && context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.restartToApply))); + } + } + @override void dispose() { _disposed = true; super.dispose(); } } + +class _ServerEndpointDialog extends StatefulWidget { + const _ServerEndpointDialog({required this.l10n}); + + final AppLocalizations l10n; + + @override + State<_ServerEndpointDialog> createState() => _ServerEndpointDialogState(); +} + +class _ServerEndpointDialogState extends State<_ServerEndpointDialog> { + late final TextEditingController _controller = TextEditingController( + text: AppConfig.apiBaseUrl, + ); + String? _error; + bool _saving = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _save() async { + final normalized = ServerEndpoint.normalize(_controller.text); + if (normalized == null) { + setState(() => _error = widget.l10n.invalidServerAddress); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final saved = await ServerEndpoint.save(normalized); + if (!mounted) return; + if (!saved) { + setState(() { + _saving = false; + _error = widget.l10n.invalidServerAddress; + }); + return; + } + Navigator.pop(context, true); + } + + Future _restoreDefault() async { + setState(() => _saving = true); + await ServerEndpoint.clear(); + if (mounted) Navigator.pop(context, true); + } + + @override + Widget build(BuildContext context) { + final l10n = widget.l10n; + return AlertDialog( + title: Text(l10n.serverAddress), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const Key('server-endpoint-input'), + controller: _controller, + enabled: !_saving, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: InputDecoration( + hintText: l10n.serverAddressHint, + errorText: _error, + errorMaxLines: 2, + ), + ), + const SizedBox(height: 10), + Text( + l10n.restartToApply, + key: const Key('server-endpoint-restart-hint'), + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + const SizedBox(height: 4), + TextButton( + key: const Key('server-endpoint-restore'), + onPressed: _saving ? null : _restoreDefault, + child: Text(l10n.restoreDefault), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + key: const Key('server-endpoint-save'), + onPressed: _saving ? null : _save, + child: Text(l10n.saveAddress), + ), + ], + ); + } +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index d0fe868..9ed63bd 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -41,6 +42,7 @@ class MyPage extends StatefulWidget { this.onSignOut, this.dramas, this.onDramaSelected, + this.debugShowSetting = kDebugMode, }); final Drama drama; @@ -57,6 +59,7 @@ class MyPage extends StatefulWidget { final VoidCallback? onSignOut; final List? dramas; final ValueChanged? onDramaSelected; + final bool debugShowSetting; @override State createState() => _MyPageState(); @@ -214,7 +217,10 @@ class _MyPageState extends State { leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft), title: Text(l10n.appLanguage), trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => logic.showLanguage(context), + onTap: () => logic.showLanguage( + context, + showServerSetting: widget.debugShowSetting, + ), ), ]; @@ -471,7 +477,10 @@ class _MyPageState extends State { ), title: Text(l10n.settings), trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => logic.showLanguage(context), + onTap: () => logic.showLanguage( + context, + showServerSetting: widget.debugShowSetting, + ), ), ], ), diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart new file mode 100644 index 0000000..974b42b --- /dev/null +++ b/test/config/server_endpoint_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/config/app_config.dart'; +import 'package:fruitshorts/core/config/server_endpoint.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + AppConfig.clearRuntimeBaseUrl(); + }); + + tearDown(AppConfig.clearRuntimeBaseUrl); + + test('should_normalize_http_urls_and_reject_invalid_values', () { + expect( + ServerEndpoint.normalize(' https://192.168.1.9:8081/// '), + 'https://192.168.1.9:8081', + ); + expect( + ServerEndpoint.normalize('http://example.test:8081/path/'), + 'http://example.test:8081/path', + ); + expect(ServerEndpoint.normalize('ftp://example.test'), isNull); + expect(ServerEndpoint.normalize('example.test:8081'), isNull); + expect(ServerEndpoint.normalize('http://'), isNull); + }); + + test('should_prioritize_runtime_over_dart_define_over_default', () { + expect( + ServerEndpoint.resolve( + stored: ' http://runtime.test:8081/ ', + compiled: 'http://define.test:8081', + fallback: 'http://default.test:8081', + ), + 'http://runtime.test:8081', + ); + expect( + ServerEndpoint.resolve( + stored: 'ftp://invalid', + compiled: 'http://define.test:8081/', + fallback: 'http://default.test:8081', + ), + 'http://define.test:8081', + ); + expect( + ServerEndpoint.resolve( + stored: null, + compiled: '', + fallback: 'http://default.test:8081/', + ), + 'http://default.test:8081', + ); + }); + + test('should_apply_stored_url_only_when_debug_is_enabled', () async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + ServerEndpoint.preferenceKey: ' http://192.168.31.187:8081/ ', + }); + final original = AppConfig.apiBaseUrl; + + await ServerEndpoint.load(debug: false); + expect(AppConfig.apiBaseUrl, original); + + await ServerEndpoint.load(debug: true); + expect(AppConfig.apiBaseUrl, 'http://192.168.31.187:8081'); + }); + + test('should_save_normalized_url_and_clear_override', () async { + expect(await ServerEndpoint.save('ftp://invalid'), isFalse); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + isNull, + ); + + expect(await ServerEndpoint.save(' http://192.168.31.187:8081/ '), isTrue); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + 'http://192.168.31.187:8081', + ); + + await ServerEndpoint.clear(); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + isNull, + ); + }); +} diff --git a/test/mine/server_endpoint_setting_test.dart b/test/mine/server_endpoint_setting_test.dart new file mode 100644 index 0000000..7eb9c49 --- /dev/null +++ b/test/mine/server_endpoint_setting_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/config/locale_controller.dart'; +import 'package:fruitshorts/core/config/server_endpoint.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/mine/mine_logic.dart'; + +const _drama = Drama( + id: 'drama-1', + title: 'The Signal', + synopsis: 'synopsis', + posterUrl: '/files/1.jpg', +); + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_show_server_address_when_debug_entry_is_enabled', ( + tester, + ) async { + await _pumpSettings(tester, showServerSetting: true); + expect(find.byKey(const Key('server-endpoint-setting')), findsOneWidget); + expect(find.text('Server address'), findsOneWidget); + }); + + testWidgets('should_hide_server_address_when_debug_entry_is_disabled', ( + tester, + ) async { + await _pumpSettings(tester, showServerSetting: false); + expect(find.byKey(const Key('server-endpoint-setting')), findsNothing); + expect(find.text('Server address'), findsNothing); + }); + + testWidgets('should_reject_invalid_server_address_without_saving', ( + tester, + ) async { + await _pumpSettings(tester, showServerSetting: true); + await tester.tap(find.byKey(const Key('server-endpoint-setting'))); + await tester.pumpAndSettle(); + + expect(find.text('Restart the app to apply.'), findsOneWidget); + await tester.enterText( + find.byKey(const Key('server-endpoint-input')), + 'ftp://invalid', + ); + await tester.tap(find.byKey(const Key('server-endpoint-save'))); + await tester.pumpAndSettle(); + + expect(find.text('Enter a valid http(s) server address.'), findsOneWidget); + expect( + await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), + isNull, + ); + }); +} + +Future _pumpSettings( + WidgetTester tester, { + required bool showServerSetting, +}) async { + final logic = MineLogic( + history: InMemoryWatchHistoryRepository(), + drama: _drama, + initialSection: 0, + ); + addTearDown(logic.dispose); + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => LocaleController(), + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open-settings'), + onPressed: () => logic.showLanguage( + context, + showServerSetting: showServerSetting, + ), + child: const Text('Open settings'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open-settings'))); + await tester.pumpAndSettle(); +}