From c259b0b543b4e9acea6c370cede40884f31a5462 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 18:23:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=E5=A2=9E=E5=8A=A0=20debug=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=9C=B0=E5=9D=80=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 7 +- lib/core/config/server_endpoint.dart | 1 + lib/core/widgets/catalog_debug_info.dart | 9 +- lib/core/widgets/catalog_endpoint_editor.dart | 126 ++++++++++++++++++ lib/pages/discover/discover_view.dart | 11 +- lib/pages/home/home_view.dart | 18 ++- test/config/server_endpoint_test.dart | 17 +++ test/discover/home_all_dramas_grid_test.dart | 4 + test/home/catalog_diagnostics_test.dart | 110 +++++++++++++++ 9 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 lib/core/widgets/catalog_endpoint_editor.dart diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 256a2d6..d73a1fe 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -80,7 +80,12 @@ class AppConfig { Future Function(String baseUrl)? probe, String? fallbackBaseUrl, }) async { - for (final candidate in _probeCandidates) { + final candidates = [ + if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl, + for (final candidate in _probeCandidates) + if (candidate != _runtimeBaseUrl) candidate, + ]; + for (final candidate in candidates) { final result = probe == null ? await _probeBaseUrl(candidate) : await _runInjectedProbe(candidate, probe); diff --git a/lib/core/config/server_endpoint.dart b/lib/core/config/server_endpoint.dart index 332eb6b..b5c6b5f 100644 --- a/lib/core/config/server_endpoint.dart +++ b/lib/core/config/server_endpoint.dart @@ -69,6 +69,7 @@ abstract final class ServerEndpoint { preferenceKey, normalized, ); + AppConfig.applyRuntimeBaseUrl(normalized); return true; } catch (_) { return false; diff --git a/lib/core/widgets/catalog_debug_info.dart b/lib/core/widgets/catalog_debug_info.dart index 479da40..bd074a5 100644 --- a/lib/core/widgets/catalog_debug_info.dart +++ b/lib/core/widgets/catalog_debug_info.dart @@ -14,9 +14,10 @@ const TextStyle _debugStyle = TextStyle( /// 仅 debug 展示当前服务地址与目录请求的原始错误。 class CatalogDebugInfo extends StatelessWidget { - const CatalogDebugInfo({super.key, required this.error}); + const CatalogDebugInfo({super.key, required this.error, this.onEditEndpoint}); final String? error; + final VoidCallback? onEditEndpoint; @override Widget build(BuildContext context) { @@ -41,6 +42,12 @@ class CatalogDebugInfo extends StatelessWidget { textAlign: TextAlign.center, style: _debugStyle, ), + if (onEditEndpoint != null) + TextButton( + key: const Key('catalog-debug-endpoint-edit'), + onPressed: onEditEndpoint, + child: const Text('设置服务地址'), + ), ], ); } diff --git a/lib/core/widgets/catalog_endpoint_editor.dart b/lib/core/widgets/catalog_endpoint_editor.dart new file mode 100644 index 0000000..e5922ba --- /dev/null +++ b/lib/core/widgets/catalog_endpoint_editor.dart @@ -0,0 +1,126 @@ +// Author: fruit.com.cn + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; +import '../config/server_endpoint.dart'; + +const String _title = '设置服务地址'; +const String _save = '保存'; +const String _cancel = '取消'; +const String _invalid = '请输入合法地址,例如 http://192.168.1.10:8081'; +const String _saveFailed = '服务地址保存失败'; +const String _localNetworkHint = '手机需与电脑同一 Wi-Fi,并在 iOS 设置-隐私与安全性-本地网络 中允许本应用'; + +/// Debug 下编辑服务地址,保存后重新探测并加载目录。 +Future showCatalogEndpointEditor( + BuildContext context, { + required Future Function() reload, + Future Function(String value)? save, + Future Function()? probe, +}) async { + if (!kDebugMode) return; + final value = await showDialog( + context: context, + builder: (_) => _CatalogEndpointDialog(initialValue: AppConfig.apiBaseUrl), + ); + if (value == null) return; + final saveEndpoint = save ?? (input) => ServerEndpoint.save(input); + if (!await saveEndpoint(value)) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text(_saveFailed))); + } + return; + } + await (probe ?? () => AppConfig.probeAndApplyBaseUrl())(); + await reload(); +} + +class _CatalogEndpointDialog extends StatefulWidget { + const _CatalogEndpointDialog({required this.initialValue}); + + final String initialValue; + + @override + State<_CatalogEndpointDialog> createState() => _CatalogEndpointDialogState(); +} + +class _CatalogEndpointDialogState extends State<_CatalogEndpointDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initialValue, + ); + String? _error; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final normalized = ServerEndpoint.normalize(_controller.text); + if (normalized == null) { + setState(() => _error = _invalid); + return; + } + Navigator.of(context).pop(normalized); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text(_title), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const Key('catalog-debug-endpoint-input'), + controller: _controller, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + autocorrect: false, + onSubmitted: (_) => _submit(), + decoration: const InputDecoration( + labelText: '服务地址', + hintText: 'http://IP:端口', + ), + ), + const SizedBox(height: 10), + const Text( + _localNetworkHint, + key: Key('catalog-debug-endpoint-hint'), + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + key: const Key('catalog-debug-endpoint-error'), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text(_cancel), + ), + FilledButton( + key: const Key('catalog-debug-endpoint-save'), + onPressed: _submit, + child: const Text(_save), + ), + ], + ); + } +} diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index d3977a4..d713627 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -18,6 +18,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/catalog_debug_info.dart'; +import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../filter/filter_page.dart'; @@ -258,7 +259,11 @@ class _DiscoverPageState extends State { actionLabel: l10n.retry, onAction: () => unawaited(_retryCategories(categories)), footer: kDebugMode - ? CatalogDebugInfo(error: categories.debugError ?? error) + ? CatalogDebugInfo( + error: categories.debugError ?? error, + onEditEndpoint: () => + unawaited(_editCategoriesEndpoint(categories)), + ) : null, ), ), @@ -518,6 +523,10 @@ class _DiscoverPageState extends State { return retryCatalogLoad(reload: categories.load); } + Future _editCategoriesEndpoint(CategoryController categories) { + return showCatalogEndpointEditor(context, reload: categories.load); + } + void _act(DiscoverPageAction action) { logic.act( context, diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 90ad457..66908e1 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -16,6 +16,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/catalog_debug_info.dart'; +import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_types.dart'; @@ -79,6 +80,10 @@ class _HomePageState extends State with WidgetsBindingObserver { return retryCatalogLoad(reload: catalog.load); } + Future _editHomeEndpoint(CatalogController catalog) { + return showCatalogEndpointEditor(context, reload: catalog.load); + } + @override Widget build(BuildContext context) { final catalog = context.watch(); @@ -107,6 +112,7 @@ class _HomePageState extends State with WidgetsBindingObserver { body: HomeFailureView( error: catalog.debugError ?? catalog.error, onRetry: () => unawaited(_retryHome(catalog)), + onEditEndpoint: () => unawaited(_editHomeEndpoint(catalog)), ), ); } @@ -134,10 +140,16 @@ class _HomePageState extends State with WidgetsBindingObserver { /// 首页目录加载失败与重试入口。 class HomeFailureView extends StatelessWidget { - const HomeFailureView({super.key, this.error, required this.onRetry}); + const HomeFailureView({ + super.key, + this.error, + required this.onRetry, + this.onEditEndpoint, + }); final String? error; final VoidCallback onRetry; + final VoidCallback? onEditEndpoint; @override Widget build(BuildContext context) { @@ -147,7 +159,9 @@ class HomeFailureView extends StatelessWidget { title: l10n.catalogError, actionLabel: l10n.retry, onAction: onRetry, - footer: kDebugMode ? CatalogDebugInfo(error: error) : null, + footer: kDebugMode + ? CatalogDebugInfo(error: error, onEditEndpoint: onEditEndpoint) + : null, ); } } diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 159e199..e0f7b08 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -113,6 +113,22 @@ void main() { expect(AppConfig.apiBaseUrl, AppConfig.devHostBaseUrl); }); + test('should_probe_explicit_runtime_endpoint_first', () async { + const custom = 'http://custom.test:8081'; + final attempts = []; + AppConfig.applyRuntimeBaseUrl(custom); + + await AppConfig.probeAndApplyBaseUrl( + probe: (baseUrl) async { + attempts.add(baseUrl); + return baseUrl == custom; + }, + ); + + expect(attempts, [custom]); + expect(AppConfig.apiBaseUrl, custom); + }); + test('should_apply_stored_url_only_when_debug_is_enabled', () async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ @@ -139,6 +155,7 @@ void main() { await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), 'http://192.168.31.187:8081', ); + expect(AppConfig.apiBaseUrl, 'http://192.168.31.187:8081'); await ServerEndpoint.clear(); expect( diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index d9d72b9..38c4860 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -137,6 +137,10 @@ void main() { expect(find.text('Retry'), findsOneWidget); expect(find.byKey(const Key('catalog-debug-endpoint')), findsOneWidget); expect(find.byKey(const Key('catalog-debug-error')), findsOneWidget); + expect( + find.byKey(const Key('catalog-debug-endpoint-edit')), + findsOneWidget, + ); expect(find.textContaining('discover feed unavailable'), findsOneWidget); }); diff --git a/test/home/catalog_diagnostics_test.dart b/test/home/catalog_diagnostics_test.dart index fffcbbf..4c6aefc 100644 --- a/test/home/catalog_diagnostics_test.dart +++ b/test/home/catalog_diagnostics_test.dart @@ -1,11 +1,14 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/config/app_config.dart'; import 'package:fruitshorts/core/data/services/catalog_retry.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/home/home_view.dart'; @@ -25,6 +28,7 @@ void main() { body: HomeFailureView( error: 'SocketException: Connection refused', onRetry: () {}, + onEditEndpoint: () {}, ), ), ), @@ -45,6 +49,10 @@ void main() { expect(error.style?.fontSize, 12); expect(error.style?.color, AppColors.subtle); expect(error.maxLines, 2); + expect( + find.byKey(const Key('catalog-debug-endpoint-edit')), + findsOneWidget, + ); }); test('should_probe_before_reloading_catalog', () async { @@ -57,4 +65,106 @@ void main() { expect(steps, ['probe', 'load']); }); + + testWidgets('should_save_probe_and_reload_after_valid_endpoint', ( + tester, + ) async { + AppConfig.applyRuntimeBaseUrl('http://127.0.0.1:8081'); + final steps = []; + await _pumpEndpointEditor( + tester, + save: (value) async { + steps.add('save:$value'); + return true; + }, + probe: () async => steps.add('probe'), + reload: () async => steps.add('load'), + ); + + expect( + tester + .widget( + find.byKey(const Key('catalog-debug-endpoint-input')), + ) + .controller + ?.text, + 'http://127.0.0.1:8081', + ); + expect( + find.byKey(const Key('catalog-debug-endpoint-hint')), + findsOneWidget, + ); + await tester.enterText( + find.byKey(const Key('catalog-debug-endpoint-input')), + 'http://10.0.0.8:8081///', + ); + await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save'))); + await tester.pumpAndSettle(); + + expect(steps, ['save:http://10.0.0.8:8081', 'probe', 'load']); + }); + + testWidgets('should_reject_invalid_endpoint_without_saving', (tester) async { + var saves = 0; + var probes = 0; + var loads = 0; + await _pumpEndpointEditor( + tester, + save: (_) async { + saves++; + return true; + }, + probe: () async => probes++, + reload: () async => loads++, + ); + + await tester.enterText( + find.byKey(const Key('catalog-debug-endpoint-input')), + 'not a valid url', + ); + await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save'))); + await tester.pump(); + + expect( + find.byKey(const Key('catalog-debug-endpoint-error')), + findsOneWidget, + ); + expect( + find.byKey(const Key('catalog-debug-endpoint-input')), + findsOneWidget, + ); + expect(saves, 0); + expect(probes, 0); + expect(loads, 0); + }); +} + +Future _pumpEndpointEditor( + WidgetTester tester, { + required Future Function(String value) save, + required Future Function() probe, + required Future Function() reload, +}) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open-endpoint-editor'), + onPressed: () => unawaited( + showCatalogEndpointEditor( + context, + save: save, + probe: probe, + reload: reload, + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open-endpoint-editor'))); + await tester.pumpAndSettle(); }