diff --git a/.gitignore b/.gitignore index 2469451..d1b4210 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,6 @@ temp/ # OS specific .DS_Store Thumbs.db + +# macOS metadata files +._* diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbdf63..9cff798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 此项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html) 规范。 +## [1.1.0] - 2026-01-20 + +### 新增 (New) +- **密码保护 (Password Protection)**: 支持设置 `password` 为面板添加安全锁,防止非授权查看。 +- **内置拦截器 (Built-in Interceptor)**: 新增 `YxNetInspectorDioInterceptor`,一行代码集成 Dio。 +- **cURL 支持**: 详情页新增 cURL 命令生成与复制功能。 +- **Headers 详情**: 详情页新增独立的请求/响应 Headers 展示卡片。 +- **API 增强**: `logResponse` 新增 `responseHeaders` 参数。 + +### 优化 (Improvements) +- 优化长文本展示,支持自动换行与滚动。 +- 修复非全屏模式下 Header 溢出问题。 + ## [1.0.0] - 2024-12-20 ### 新增 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..01bb970 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,13 @@ +include: package:very_good_analysis/analysis_options.yaml + +linter: + rules: + public_member_api_docs: false + sort_pub_dependencies: false + +analyzer: + errors: + cascade_invocations: ignore + inference_failure_on_collection_literal: ignore + lines_longer_than_80_chars: ignore # Too strict for existing code + diff --git a/debug_test.dart b/debug_test.dart index 60725cc..0e1e68e 100644 --- a/debug_test.dart +++ b/debug_test.dart @@ -14,19 +14,8 @@ class MyDebugApp extends StatelessWidget { title: 'YX 网络检查器调试', home: YxNetInspector( config: const YxNetInspectorConfig( - showFloatingBall: true, - ballSize: 60, ballColor: Colors.blue, ), - theme: const YxNetInspectorTheme( - primaryColor: Colors.blue, - backgroundColor: Colors.white, - textColor: Colors.black87, - successColor: Colors.green, - errorColor: Colors.red, - warningColor: Colors.orange, - cardColor: Colors.white, - ), child: Scaffold( appBar: AppBar( title: const Text('调试测试'), @@ -55,7 +44,7 @@ class MyDebugApp extends StatelessWidget { statusCode: 200, responseData: const { 'title': 'Test Post', - 'body': 'Test content' + 'body': 'Test content', }, duration: const Duration(seconds: 1), ); diff --git a/example/pubspec.lock b/example/pubspec.lock index 6176505..069d69f 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + dio: + dependency: transitive + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: @@ -87,26 +103,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.flutter-io.cn" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -139,6 +155,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" path: dependency: transitive description: @@ -196,10 +220,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.4" + version: "0.7.6" typed_data: dependency: transitive description: @@ -212,10 +236,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -238,7 +262,7 @@ packages: path: ".." relative: true source: path - version: "1.0.2" + version: "1.0.4" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/controller/yx_net_inspector_controller.dart b/lib/src/controller/yx_net_inspector_controller.dart index 3335af0..cc2f84b 100644 --- a/lib/src/controller/yx_net_inspector_controller.dart +++ b/lib/src/controller/yx_net_inspector_controller.dart @@ -1,24 +1,22 @@ +// ignore_for_file: prefer_constructors_over_static_methods // Singleton pattern requires static access import 'package:flutter/foundation.dart'; -import '../models/network_log_entry.dart'; -import '../models/inspector_config.dart'; +import 'package:yx_net_inspector/src/models/inspector_config.dart'; +import 'package:yx_net_inspector/src/models/network_log_entry.dart'; /// 网络日志检查器控制器 /// 管理网络日志和检查器状态 class YxNetInspectorController extends ChangeNotifier { + YxNetInspectorController._internal(); static YxNetInspectorController? _instance; static YxNetInspectorController get instance { return _instance ??= YxNetInspectorController._internal(); } - YxNetInspectorController._internal(); - /// 配置信息 late YxNetInspectorConfig _config; - YxNetInspectorConfig get config => _config; /// 网络日志列表 final List _logs = []; - List get logs => List.unmodifiable(_logs); /// 统计数据 int _requestCount = 0; @@ -33,12 +31,72 @@ class YxNetInspectorController extends ChangeNotifier { /// 悬浮球显示状态 bool _showFloatingBall = true; - bool get showFloatingBall => _showFloatingBall && _config.isEnabled; - /// 初始化控制器配置 + /// 面板显示状态 + bool _isPanelVisible = false; + bool _isUnlocked = true; // 默认为已解锁 + + /// 初始化控制器 void initialize(YxNetInspectorConfig config) { _config = config; _showFloatingBall = config.showFloatingBall; + + // 如果配置了密码,初始化为锁定状态 + if (config.password != null && config.password!.isNotEmpty) { + _isUnlocked = false; + } + } + + /// 获取当前配置 + YxNetInspectorConfig get config => _config; + + /// 获取日志列表 + List get logs => List.unmodifiable(_logs); + + /// 获取悬浮球可见性 + bool get showFloatingBall => _showFloatingBall; + + /// 获取面板可见性 + bool get isPanelVisible => _isPanelVisible; + + /// 获取是否已解锁 + bool get isUnlocked => _isUnlocked; + + /// 尝试解锁 + /// 返回是否成功 + Future unlock(String password) async { + if (_config.password == password) { + _isUnlocked = true; + notifyListeners(); + return true; + } + return false; + } + + /// 锁定检查器 + void lock() { + if (_config.password != null) { + _isUnlocked = false; + notifyListeners(); + } + } + + /// 显示面板 + void showPanel() { + _isPanelVisible = true; + notifyListeners(); + } + + /// 隐藏面板 + void hidePanel() { + _isPanelVisible = false; + notifyListeners(); + } + + /// 切换面板显示状态 + void togglePanel() { + _isPanelVisible = !_isPanelVisible; + notifyListeners(); } /// 记录网络请求 @@ -61,7 +119,6 @@ class YxNetInspectorController extends ChangeNotifier { queryParameters: queryParameters, timestamp: DateTime.now(), isSuccess: false, // 响应到达时会更新 - status: NetworkRequestStatus.pending, // 初始状态为进行中 ); _logs.insert(0, entry); @@ -76,6 +133,7 @@ class YxNetInspectorController extends ChangeNotifier { int? statusCode, dynamic responseData, Duration? duration, + Map? responseHeaders, }) { if (!_config.isEnabled) return; @@ -89,6 +147,7 @@ class YxNetInspectorController extends ChangeNotifier { final updatedLog = originalLog.copyWith( statusCode: statusCode, responseData: responseData, + responseHeaders: responseHeaders, duration: duration, isSuccess: isSuccess, status: isSuccess @@ -214,6 +273,43 @@ class YxNetInspectorController extends ChangeNotifier { }).toList(); } + /// 获取所有日志的字符串表示 (用于导出/分享) + String getAllLogsAsString() { + final buffer = StringBuffer(); + buffer.writeln('YxNetInspector Exported Logs'); + buffer.writeln('Generated at: ${DateTime.now()}'); + buffer.writeln('Total Requests: $requestCount'); + buffer.writeln('Success: $successCount, Failed: $_errorCount'); + buffer.writeln('----------------------------------------'); + + for (final log in _logs) { + buffer.writeln('[${log.method}] ${log.url}'); + buffer.writeln('Status: ${log.statusCode ?? "N/A"}'); + buffer.writeln('Time: ${log.timestamp}'); + buffer.writeln('Duration: ${log.formattedDuration}'); + + if (log.headers != null && log.headers!.isNotEmpty) { + buffer.writeln('Headers: ${log.headers}'); + } + + if (log.requestData != null) { + buffer.writeln('Request Data: ${log.requestData}'); + } + + if (log.responseData != null) { + buffer.writeln('Response Data: ${log.responseData}'); + } + + if (log.errorMessage != null) { + buffer.writeln('Error: ${log.errorMessage}'); + } + + buffer.writeln('----------------------------------------'); + } + + return buffer.toString(); + } + /// 限制日志数量 void _trimLogs() { if (_logs.length > _config.maxLogs) { diff --git a/lib/src/interceptors/dio_interceptor.dart b/lib/src/interceptors/dio_interceptor.dart new file mode 100644 index 0000000..f851754 --- /dev/null +++ b/lib/src/interceptors/dio_interceptor.dart @@ -0,0 +1,62 @@ +import 'package:dio/dio.dart'; +import 'package:yx_net_inspector/src/yx_net_inspector_app.dart'; + +/// 网络检查器 Dio 拦截器 +/// +/// 用于自动记录 Dio 请求和响应日志到 YxNetInspector +class YxNetInspectorDioInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // 记录请求 + YxNetInspectorGlobal.logRequest( + id: options.hashCode.toString(), + method: options.method, + url: options.uri.toString(), + headers: options.headers, + requestData: options.data, + queryParameters: options.queryParameters, + ); + handler.next(options); + } + + @override + void onResponse( + Response response, ResponseInterceptorHandler handler,) { + // 记录响应 + try { + // 计算请求耗时(如果可能) + // 注意:Dio 的 RequestOptions 不带时间戳,我们这里可能依赖 Controller 内部的计时, + // 或者我们可以扩展 RequestOptions 来携带时间戳 (extra) + final requestOptions = response.requestOptions; + + YxNetInspectorGlobal.logResponse( + id: requestOptions.hashCode.toString(), + statusCode: response.statusCode, + responseData: response.data, + responseHeaders: response.headers.map, + // 这里没有准确的耗时,因为 logRequest 记录了开始时间。 + // Controller 会根据 ID 自动计算耗时:Duration = Now - StartTime + ); + } on Object catch (_) { + // 忽略日志记录错误,避免影响业务 + } + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // 记录错误 + try { + final requestOptions = err.requestOptions; + + YxNetInspectorGlobal.logError( + id: requestOptions.hashCode.toString(), + error: err.message ?? err.toString(), + statusCode: err.response?.statusCode, + ); + } on Object catch (_) { + // 忽略日志记录错误 + } + handler.next(err); + } +} diff --git a/lib/src/models/inspector_config.dart b/lib/src/models/inspector_config.dart index 37a6be4..bd8ce8a 100644 --- a/lib/src/models/inspector_config.dart +++ b/lib/src/models/inspector_config.dart @@ -2,7 +2,22 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; /// 网络检查器配置 +@immutable class YxNetInspectorConfig { + const YxNetInspectorConfig({ + this.showFloatingBall = true, + this.ballSize = 60.0, + this.ballColor, + this.showInDebugMode = true, + this.showInReleaseMode = false, + this.maxLogs = 1000, + this.initialPosition, + this.draggable = true, + this.showBadge = true, + this.autoHide = false, + this.password, + }); + /// 是否显示悬浮球 final bool showFloatingBall; @@ -33,18 +48,8 @@ class YxNetInspectorConfig { /// 是否自动隐藏悬浮球 final bool autoHide; - const YxNetInspectorConfig({ - this.showFloatingBall = true, - this.ballSize = 60.0, - this.ballColor, - this.showInDebugMode = true, - this.showInReleaseMode = false, - this.maxLogs = 1000, - this.initialPosition, - this.draggable = true, - this.showBadge = true, - this.autoHide = false, - }); + /// 访问检查器面板所需的密码(如果为空则无需密码) + final String? password; /// 根据当前模式判断检查器是否应该启用 bool get isEnabled { @@ -67,6 +72,7 @@ class YxNetInspectorConfig { bool? draggable, bool? showBadge, bool? autoHide, + String? password, }) { return YxNetInspectorConfig( showFloatingBall: showFloatingBall ?? this.showFloatingBall, @@ -79,6 +85,7 @@ class YxNetInspectorConfig { draggable: draggable ?? this.draggable, showBadge: showBadge ?? this.showBadge, autoHide: autoHide ?? this.autoHide, + password: password ?? this.password, ); } @@ -94,7 +101,8 @@ class YxNetInspectorConfig { 'initialPosition: $initialPosition, ' 'draggable: $draggable, ' 'showBadge: $showBadge, ' - 'autoHide: $autoHide' + 'autoHide: $autoHide, ' + 'password: ${password != null ? "***" : "null"}' ')'; } @@ -111,7 +119,8 @@ class YxNetInspectorConfig { other.initialPosition == initialPosition && other.draggable == draggable && other.showBadge == showBadge && - other.autoHide == autoHide; + other.autoHide == autoHide && + other.password == password; } @override @@ -127,6 +136,7 @@ class YxNetInspectorConfig { draggable, showBadge, autoHide, + password, ); } } diff --git a/lib/src/models/inspector_theme.dart b/lib/src/models/inspector_theme.dart index 4dc931c..de7eb96 100644 --- a/lib/src/models/inspector_theme.dart +++ b/lib/src/models/inspector_theme.dart @@ -1,7 +1,40 @@ import 'package:flutter/material.dart'; /// 网络检查器主题配置 +@immutable class YxNetInspectorTheme { + const YxNetInspectorTheme({ + this.primaryColor = Colors.blue, + this.backgroundColor = Colors.white, + this.textColor = Colors.black87, + this.secondaryTextColor = Colors.grey, + this.errorColor = Colors.red, + this.successColor = Colors.green, + this.warningColor = Colors.orange, + this.borderColor = const Color(0xFFE0E0E0), + this.cardColor = Colors.white, + this.floatingBallColor, + }); + + /// 创建暗色主题 + factory YxNetInspectorTheme.dark() { + return const YxNetInspectorTheme( + primaryColor: Colors.blueAccent, + backgroundColor: Color(0xFF121212), + textColor: Colors.white, + errorColor: Colors.redAccent, + successColor: Colors.greenAccent, + warningColor: Colors.orangeAccent, + borderColor: Color(0xFF333333), + cardColor: Color(0xFF1E1E1E), + ); + } + + /// 创建亮色主题(默认) + factory YxNetInspectorTheme.light() { + return const YxNetInspectorTheme(); + } + /// 检查器 UI 的主色调 final Color primaryColor; @@ -32,39 +65,6 @@ class YxNetInspectorTheme { /// 悬浮球颜色(如果设置则覆盖配置) final Color? floatingBallColor; - const YxNetInspectorTheme({ - this.primaryColor = Colors.blue, - this.backgroundColor = Colors.white, - this.textColor = Colors.black87, - this.secondaryTextColor = Colors.grey, - this.errorColor = Colors.red, - this.successColor = Colors.green, - this.warningColor = Colors.orange, - this.borderColor = const Color(0xFFE0E0E0), - this.cardColor = Colors.white, - this.floatingBallColor, - }); - - /// 创建暗色主题 - factory YxNetInspectorTheme.dark() { - return const YxNetInspectorTheme( - primaryColor: Colors.blueAccent, - backgroundColor: Color(0xFF121212), - textColor: Colors.white, - secondaryTextColor: Colors.grey, - errorColor: Colors.redAccent, - successColor: Colors.greenAccent, - warningColor: Colors.orangeAccent, - borderColor: Color(0xFF333333), - cardColor: Color(0xFF1E1E1E), - ); - } - - /// 创建亮色主题(默认) - factory YxNetInspectorTheme.light() { - return const YxNetInspectorTheme(); - } - /// 创建一个带有更新值的副本 YxNetInspectorTheme copyWith({ Color? primaryColor, diff --git a/lib/src/models/network_log_entry.dart b/lib/src/models/network_log_entry.dart index 1f80116..475f514 100644 --- a/lib/src/models/network_log_entry.dart +++ b/lib/src/models/network_log_entry.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; /// 网络请求状态枚举 @@ -13,11 +15,29 @@ enum NetworkRequestStatus { } /// 网络请求日志条目 +@immutable class NetworkLogEntry { + const NetworkLogEntry({ + required this.id, + required this.method, + required this.url, + required this.timestamp, + required this.isSuccess, + this.headers, + this.responseHeaders, + this.requestData, + this.queryParameters, + this.statusCode, + this.responseData, + this.errorMessage, + this.duration, + this.status = NetworkRequestStatus.pending, + }); final String id; final String method; final String url; final Map? headers; + final Map? responseHeaders; final dynamic requestData; final Map? queryParameters; final int? statusCode; @@ -28,22 +48,6 @@ class NetworkLogEntry { final bool isSuccess; final NetworkRequestStatus status; - NetworkLogEntry({ - required this.id, - required this.method, - required this.url, - this.headers, - this.requestData, - this.queryParameters, - this.statusCode, - this.responseData, - this.errorMessage, - required this.timestamp, - this.duration, - required this.isSuccess, - this.status = NetworkRequestStatus.pending, - }); - /// 根据请求状态获取状态颜色 Color get statusColor { switch (status) { @@ -70,7 +74,7 @@ class NetworkLogEntry { /// 获取预估请求大小 int get requestSize { - int size = 0; + var size = 0; if (requestData != null) { size += requestData.toString().length; } @@ -102,7 +106,7 @@ class NetworkLogEntry { String get formattedDuration { if (duration == null) return '未知'; final ms = duration!.inMilliseconds; - if (ms < 1000) return '${ms} ms'; + if (ms < 1000) return '$ms ms'; return '${(ms / 1000).toStringAsFixed(1)} s'; } @@ -111,7 +115,7 @@ class NetworkLogEntry { try { final uri = Uri.parse(url); return '${uri.path}${uri.query.isNotEmpty ? '?${uri.query}' : ''}'; - } catch (e) { + } on Object catch (_) { return url; } } @@ -119,18 +123,45 @@ class NetworkLogEntry { String get hostUrl { try { final uri = Uri.parse(url); - return '${uri.host}'; - } catch (e) { + return uri.host; + } on Object catch (_) { return url; } } + /// 生成 cURL 命令用于调试 (跨平台兼容: macOS/Linux/Windows) + String toCurlCommand() { + final buffer = StringBuffer('curl -X $method "$url"'); + headers?.forEach((key, value) { + // 转义双引号以兼容所有平台 + final escapedValue = value.toString().replaceAll('"', r'\"'); + buffer.write(' -H "$key: $escapedValue"'); + }); + if (requestData != null) { + String body; + if (requestData is String) { + body = requestData as String; + } else { + try { + body = jsonEncode(requestData); + } on Object catch (_) { + body = requestData.toString(); + } + } + // 转义双引号和反斜杠 + final escapedBody = body.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + buffer.write(' -d "$escapedBody"'); + } + return buffer.toString(); + } + /// 创建副本并更新字段 NetworkLogEntry copyWith({ String? id, String? method, String? url, Map? headers, + Map? responseHeaders, dynamic requestData, Map? queryParameters, int? statusCode, @@ -146,6 +177,7 @@ class NetworkLogEntry { method: method ?? this.method, url: url ?? this.url, headers: headers ?? this.headers, + responseHeaders: responseHeaders ?? this.responseHeaders, requestData: requestData ?? this.requestData, queryParameters: queryParameters ?? this.queryParameters, statusCode: statusCode ?? this.statusCode, diff --git a/lib/src/widgets/floating_ball.dart b/lib/src/widgets/floating_ball.dart index adb983a..86f6b5d 100644 --- a/lib/src/widgets/floating_ball.dart +++ b/lib/src/widgets/floating_ball.dart @@ -1,22 +1,17 @@ import 'package:flutter/material.dart'; -import '../controller/yx_net_inspector_controller.dart'; -import '../models/inspector_config.dart'; -import '../models/inspector_theme.dart'; -import 'inspector_panel.dart'; +import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; +import 'package:yx_net_inspector/src/models/inspector_config.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; /// 悬浮调试球组件 class YxFloatingBall extends StatefulWidget { + const YxFloatingBall({ + required this.config, required this.theme, required this.controller, super.key, + }); final YxNetInspectorConfig config; final YxNetInspectorTheme theme; final YxNetInspectorController controller; - const YxFloatingBall({ - super.key, - required this.config, - required this.theme, - required this.controller, - }); - @override State createState() => _YxFloatingBallState(); } @@ -28,8 +23,6 @@ class _YxFloatingBallState extends State late Animation _opacityAnimation; Offset _position = const Offset(20, 200); - bool _isExpanded = false; - OverlayEntry? _currentOverlayEntry; @override void initState() { @@ -46,167 +39,23 @@ class _YxFloatingBallState extends State vsync: this, ); - _scaleAnimation = Tween(begin: 1.0, end: 1.2).animate( + _scaleAnimation = Tween(begin: 1, end: 1.2).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), ); - _opacityAnimation = Tween(begin: 1.0, end: 0.8).animate( + _opacityAnimation = Tween(begin: 1, end: 0.8).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), ); } @override void dispose() { - // 清理 overlay - if (_currentOverlayEntry != null) { - _currentOverlayEntry!.remove(); - _currentOverlayEntry = null; - } _animationController.dispose(); super.dispose(); } void _onTap() { - if (_isExpanded) { - _hideInspectorPanel(); - } else { - _showInspectorPanel(); - } - } - - void _showInspectorPanel() { - setState(() { - _isExpanded = true; - }); - _animationController.forward(); - - // 优先使用 Overlay,如果失败则使用 Navigator - _showInspectorOverlay(); - } - - void _showInspectorOverlay() { - // 延迟到下一帧执行,确保Widget树已经完全构建 - WidgetsBinding.instance.addPostFrameCallback((_) { - _tryShowOverlay(); - }); - } - - void _tryShowOverlay() { - // 尝试找到 Overlay - OverlayState? overlay; - try { - overlay = Overlay.of(context, rootOverlay: true); - } catch (e) { - debugPrint('YxNetInspector: 根 Overlay 查找失败: $e'); - // 如果 Overlay.of 失败,尝试手动查找 - overlay = _findOverlayInContext(context); - } - - if (overlay == null) { - // 如果仍然找不到 Overlay,使用备选方案 - _showInspectorDialog(); - return; - } - - try { - final overlayEntry = OverlayEntry( - builder: (context) => Material( - color: Colors.black54, - child: Stack( - children: [ - // 背景遮罩 - GestureDetector( - onTap: _hideInspectorPanel, - child: Container( - color: Colors.transparent, - width: double.infinity, - height: double.infinity, - ), - ), - // 检查器面板 - Center( - child: YxInspectorPanel( - theme: widget.theme, - controller: widget.controller, - onClose: _hideInspectorPanel, - ), - ), - ], - ), - ), - ); - - overlay.insert(overlayEntry); - _currentOverlayEntry = overlayEntry; - } catch (e) { - debugPrint('YxNetInspector: 插入 OverlayEntry 失败: $e'); - // 重置状态 - setState(() { - _isExpanded = false; - }); - _animationController.reverse(); - } - } - - OverlayState? _findOverlayInContext(BuildContext context) { - OverlayState? overlayState; - context.visitAncestorElements((element) { - if (element.widget is Overlay) { - overlayState = Overlay.of(element); - return false; // 停止遍历 - } - return true; // 继续向上查找 - }); - return overlayState; - } - - void _showInspectorDialog() { - // 作为最后的备选方案,创建一个自定义的全屏Overlay - // 这里我们不依赖Navigator,而是手动管理显示状态 - _createCustomOverlay(); - } - - void _createCustomOverlay() { - // 如果所有 Overlay 方法都失败,我们显示一个简单的调试信息 - // 并重置状态,避免悬浮球卡在展开状态 - - // 显示一个简单的 SnackBar 或 print 提示 - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - try { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('网络检查器暂时无法显示,请检查应用结构'), - duration: Duration(seconds: 2), - ), - ); - } catch (e) { - // 如果 ScaffoldMessenger 也不可用,只打印日志 - debugPrint('YxNetInspector: 无法显示检查器面板 - 请确保在 MaterialApp 内使用'); - } - - // 重置状态 - setState(() { - _isExpanded = false; - }); - _animationController.reverse(); - } - }); - } - - void _hideInspectorPanel() { - setState(() { - _isExpanded = false; - }); - _animationController.reverse(); - - // 如果有 overlay,移除它 - if (_currentOverlayEntry != null) { - _currentOverlayEntry!.remove(); - _currentOverlayEntry = null; - } - // 注意:如果使用了 Navigator 的 PageRouteBuilder, - // 对话框关闭会由 onClose 回调中的 Navigator.pop() 处理 + widget.controller.togglePanel(); } void _onPanStart(DragStartDetails details) { diff --git a/lib/src/widgets/floating_ball_config.dart b/lib/src/widgets/floating_ball_config.dart index dae47f8..ef3fa41 100644 --- a/lib/src/widgets/floating_ball_config.dart +++ b/lib/src/widgets/floating_ball_config.dart @@ -1,7 +1,50 @@ import 'package:flutter/material.dart'; /// 悬浮调试球配置 +@immutable class YxFloatingBallConfig { + const YxFloatingBallConfig({ + this.size = 60.0, + this.position = const Offset(20, 200), + this.draggable = true, + this.showBadge = true, + this.autoHide = false, + this.autoHideDuration = const Duration(seconds: 5), + this.color, + this.opacity = 1.0, + }); + + /// 创建小型悬浮球配置 + factory YxFloatingBallConfig.small({Offset? position, Color? color}) { + return YxFloatingBallConfig( + size: 40, + position: position ?? const Offset(20, 200), + color: color, + showBadge: false, + ); + } + + /// 创建大型悬浮球配置 + factory YxFloatingBallConfig.large({Offset? position, Color? color}) { + return YxFloatingBallConfig( + size: 80, + position: position ?? const Offset(20, 200), + color: color, + ); + } + + /// 创建最小化悬浮球配置 + factory YxFloatingBallConfig.minimal({Offset? position, Color? color}) { + return YxFloatingBallConfig( + size: 50, + position: position ?? const Offset(20, 200), + color: color, + showBadge: false, + draggable: false, + autoHide: true, + ); + } + /// 悬浮球大小 final double size; @@ -26,49 +69,6 @@ class YxFloatingBallConfig { /// 悬浮球透明度 final double opacity; - const YxFloatingBallConfig({ - this.size = 60.0, - this.position = const Offset(20, 200), - this.draggable = true, - this.showBadge = true, - this.autoHide = false, - this.autoHideDuration = const Duration(seconds: 5), - this.color, - this.opacity = 1.0, - }); - - /// 创建小型悬浮球配置 - factory YxFloatingBallConfig.small({Offset? position, Color? color}) { - return YxFloatingBallConfig( - size: 40.0, - position: position ?? const Offset(20, 200), - color: color, - showBadge: false, - ); - } - - /// 创建大型悬浮球配置 - factory YxFloatingBallConfig.large({Offset? position, Color? color}) { - return YxFloatingBallConfig( - size: 80.0, - position: position ?? const Offset(20, 200), - color: color, - showBadge: true, - ); - } - - /// 创建最小化悬浮球配置 - factory YxFloatingBallConfig.minimal({Offset? position, Color? color}) { - return YxFloatingBallConfig( - size: 50.0, - position: position ?? const Offset(20, 200), - color: color, - showBadge: false, - draggable: false, - autoHide: true, - ); - } - /// 创建一个带有更新值的副本 YxFloatingBallConfig copyWith({ double? size, diff --git a/lib/src/widgets/inspector_panel.dart b/lib/src/widgets/inspector_panel.dart index 01fef3c..f6468d2 100644 --- a/lib/src/widgets/inspector_panel.dart +++ b/lib/src/widgets/inspector_panel.dart @@ -1,22 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../controller/yx_net_inspector_controller.dart'; -import '../models/inspector_theme.dart'; -import '../models/network_log_entry.dart'; -import 'log_detail_page.dart'; +import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/models/network_log_entry.dart'; +import 'package:yx_net_inspector/src/widgets/log_detail_page.dart'; +import 'package:yx_net_inspector/src/widgets/panel/inspector_detail_header.dart'; +import 'package:yx_net_inspector/src/widgets/panel/inspector_header.dart'; +import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart'; +import 'package:yx_net_inspector/src/widgets/panel/inspector_search_bar.dart'; +import 'package:yx_net_inspector/src/widgets/panel/inspector_statistics.dart'; +import 'package:yx_net_inspector/src/widgets/panel/password_dialog.dart'; /// 网络检查器面板组件 class YxInspectorPanel extends StatefulWidget { - final YxNetInspectorTheme theme; - final YxNetInspectorController controller; - final VoidCallback onClose; - const YxInspectorPanel({ - super.key, required this.theme, required this.controller, required this.onClose, + super.key, }); + final YxNetInspectorTheme theme; + final YxNetInspectorController controller; + final VoidCallback onClose; @override State createState() => _YxInspectorPanelState(); @@ -52,8 +57,14 @@ class _YxInspectorPanelState extends State { }); } + void _toggleFullScreen() { + setState(() { + _isFullScreen = !_isFullScreen; + }); + } + List get _filteredLogs { - List logs = widget.controller.logs; + var logs = widget.controller.logs; // 搜索过滤 if (_searchKeyword.isNotEmpty) { @@ -70,497 +81,8 @@ class _YxInspectorPanelState extends State { return logs; } - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: widget.controller, - builder: (context, child) { - Widget content; - - if (_showDetailPage && _selectedLog != null) { - // 显示详情页面 - content = Column( - children: [ - _buildDetailHeader(), - Expanded( - child: YxLogDetailPage( - log: _selectedLog!, - theme: widget.theme, - ), - ), - ], - ); - } else { - // 显示主列表页面 - content = Column( - children: [ - _buildHeader(), - _buildStatistics(), - _buildSearchBar(), - Expanded(child: _buildLogList()), - ], - ); - } - - if (_isFullScreen) { - // 全屏模式 - 考虑状态栏高度 - return Material( - color: Colors.black.withValues(alpha: 0.5), - child: SafeArea( - child: Container( - width: MediaQuery.of(context).size.width, - height: double.infinity, - color: widget.theme.backgroundColor, - child: content, - ), - ), - ); - } else { - // 对话框模式 - return Dialog( - backgroundColor: Colors.transparent, - child: Container( - width: MediaQuery.of(context).size.width * 0.9, - height: MediaQuery.of(context).size.height * 0.8, - decoration: BoxDecoration( - color: widget.theme.backgroundColor, - borderRadius: BorderRadius.circular(16), - ), - child: content, - ), - ); - } - }, - ); - } - - Widget _buildDetailHeader() { - final actions = [ - IconButton( - onPressed: () => _copyLogDetails(_selectedLog!), - icon: const Icon( - Icons.copy, - color: Colors.white, - ), - tooltip: '复制详情', - ), - IconButton( - onPressed: () { - _isFullScreen = !_isFullScreen; - setState(() {}); - }, - icon: Icon( - _isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen, - color: Colors.white, - ), - tooltip: _isFullScreen ? '退出全屏' : '全屏显示', - ), - IconButton( - onPressed: widget.onClose, - icon: const Icon( - Icons.close, - color: Colors.white, - ), - tooltip: '关闭', - ), - ]; - - return _isFullScreen - ? AppBar( - title: Text('请求详情'), - backgroundColor: widget.theme.primaryColor, - foregroundColor: Colors.white, - // excludeHeaderSemantics: false - leading: IconButton( - onPressed: _hideLogDetail, - icon: const Icon( - Icons.arrow_back, - color: Colors.white, - ), - ), - centerTitle: false, - actions: actions, - ) - : Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: widget.theme.primaryColor, - borderRadius: _isFullScreen - ? BorderRadius.zero - : const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - child: Row( - children: [ - IconButton( - onPressed: _hideLogDetail, - icon: const Icon( - Icons.arrow_back, - color: Colors.white, - ), - tooltip: '返回列表', - ), - const Text( - '请求详情', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - ...actions - ], - ), - ); - } - - Widget _buildHeader() { - final actions = [ - IconButton( - onPressed: () { - setState(() { - _isFullScreen = !_isFullScreen; - }); - }, - icon: Icon( - _isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen, - color: Colors.white, - ), - tooltip: _isFullScreen ? '退出全屏' : '全屏', - ), - IconButton( - onPressed: () { - widget.controller.clearLogs(); - }, - icon: const Icon(Icons.clear_all, color: Colors.white), - tooltip: '清空日志', - ), - IconButton( - onPressed: widget.onClose, - icon: const Icon(Icons.close, color: Colors.white), - ), - ]; - return _isFullScreen - ? AppBar( - title: Text('网络检查器'), - backgroundColor: widget.theme.primaryColor, - foregroundColor: Colors.white, - centerTitle: false, - actions: actions, - ) - : Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - color: widget.theme.primaryColor, - borderRadius: _isFullScreen - ? BorderRadius.zero - : const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - child: Row( - children: [ - const Icon(Icons.network_check, color: Colors.white, size: 24), - const SizedBox(width: 8), - const Text( - '网络检查器', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const Spacer(), - ...actions - ], - ), - ); - } - - Widget _buildStatistics() { - final stats = widget.controller.getStatistics(); - final totalRequests = stats['totalRequests'] as int; - final errorRequests = stats['errorRequests'] as int; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: widget.theme.backgroundColor, - border: Border( - bottom: BorderSide( - color: widget.theme.secondaryTextColor.withValues(alpha: 0.2), - width: 1, - ), - ), - ), - child: Row( - children: [ - // 简化统计 - 只显示关键信息 - Text( - '总请求: $totalRequests', - style: TextStyle( - fontSize: 14, - color: widget.theme.textColor, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 16), - if (errorRequests > 0) ...[ - Icon( - Icons.error_outline, - size: 16, - color: widget.theme.errorColor, - ), - const SizedBox(width: 4), - Text( - '错误: $errorRequests', - style: TextStyle( - fontSize: 14, - color: widget.theme.errorColor, - fontWeight: FontWeight.w600, - ), - ), - ] else ...[ - Icon( - Icons.check_circle_outline, - size: 16, - color: widget.theme.successColor, - ), - const SizedBox(width: 4), - Text( - '全部成功', - style: TextStyle( - fontSize: 14, - color: widget.theme.successColor, - fontWeight: FontWeight.w500, - ), - ), - ], - const Spacer(), - // 快速过滤按钮 - if (errorRequests > 0) - TextButton.icon( - onPressed: () { - setState(() { - _showOnlyErrors = !_showOnlyErrors; - }); - }, - icon: Icon( - _showOnlyErrors ? Icons.clear : Icons.filter_list, - size: 16, - ), - label: Text(_showOnlyErrors ? '显示全部' : '仅错误'), - style: TextButton.styleFrom( - foregroundColor: widget.theme.errorColor, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ], - ), - ); - } - - Widget _buildSearchBar() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _searchController, - style: TextStyle(color: widget.theme.textColor), - decoration: InputDecoration( - hintText: '搜索请求...', - hintStyle: TextStyle(color: widget.theme.secondaryTextColor), - prefixIcon: Icon( - Icons.search, - color: widget.theme.secondaryTextColor, - ), - border: OutlineInputBorder( - borderSide: BorderSide(color: widget.theme.borderColor), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - ), - onChanged: (value) { - setState(() { - _searchKeyword = value; - }); - }, - ), - ), - ], - ), - ); - } - - Widget _buildLogList() { - final logs = _filteredLogs; - - if (logs.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.inbox, size: 64, color: widget.theme.secondaryTextColor), - const SizedBox(height: 16), - Text( - '未找到网络请求', - style: TextStyle( - fontSize: 16, - color: widget.theme.secondaryTextColor, - ), - ), - ], - ), - ); - } - - return ListView.builder( - itemCount: logs.length, - itemBuilder: (context, index) { - final log = logs[index]; - return _buildLogItem(log); - }, - ); - } - - Widget _buildLogItem(NetworkLogEntry log) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - decoration: BoxDecoration( - color: widget.theme.cardColor, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: log.status == NetworkRequestStatus.failed - ? widget.theme.errorColor.withValues(alpha: 0.3) - : Colors.transparent, - width: log.status == NetworkRequestStatus.failed ? 1 : 0, - ), - ), - child: InkWell( - onTap: () { - _showLogDetail(log); - }, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - // URL和状态 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - log.displayUrl, - style: TextStyle( - fontSize: 13, - color: widget.theme.textColor, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - children: [ - // 状态指示器 - Container( - width: 6, - height: 6, - decoration: BoxDecoration( - color: log.statusColor, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - - // 方法标签 - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: _getMethodColor(log.method) - .withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - log.method, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: _getMethodColor(log.method), - ), - ), - ), - const SizedBox(width: 8), - - Text( - '${log.statusCode ?? "?"} ${log.formattedDuration}', - style: TextStyle( - fontSize: 11, - color: log.statusColor, - fontWeight: FontWeight.w500, - ), - ), - if (log.errorMessage != null) ...[ - const SizedBox(width: 8), - Icon( - Icons.error_outline, - size: 12, - color: widget.theme.errorColor, - ), - ], - Spacer(), - // 时间 - Text( - log.formattedTime.contains(' ') - ? log.formattedTime.split(' ')[1] - : log.formattedTime, // 只显示时间部分 - style: TextStyle( - fontSize: 10, - color: widget.theme.secondaryTextColor, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Color _getMethodColor(String method) { - switch (method.toUpperCase()) { - case 'GET': - return Colors.blue; - case 'POST': - return Colors.green; - case 'PUT': - return Colors.orange; - case 'DELETE': - return Colors.red; - case 'PATCH': - return Colors.purple; - default: - return widget.theme.secondaryTextColor; - } - } - void _copyLogDetails(NetworkLogEntry log) { + // Note: This logic could be moved to util or DetailPage, but keeping here for now as callback final details = ''' 网络请求详情 ======================= @@ -600,7 +122,7 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo } else if (requestData is Map) { try { return requestData.toString(); - } catch (e) { + } on Object catch (_) { return requestData.toString(); } } else { @@ -609,7 +131,133 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo } String _formatDateTime(DateTime dateTime) { - return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ' - '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}:${dateTime.second.toString().padLeft(2, '0')}'; + return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-' + '${dateTime.day.toString().padLeft(2, '0')} ' + '${dateTime.hour.toString().padLeft(2, '0')}:' + '${dateTime.minute.toString().padLeft(2, '0')}:' + '${dateTime.second.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, child) { + // 如果未解锁,显示密码解锁界面 + if (!widget.controller.isUnlocked) { + return Center( + child: PasswordDialog( + theme: widget.theme, + onUnlock: widget.controller.unlock, + ), + ); + } + + Widget content; + + if (_showDetailPage && _selectedLog != null) { + // 显示详情页面 + content = Column( + children: [ + InspectorDetailHeader( + theme: widget.theme, + isFullScreen: _isFullScreen, + onToggleFullScreen: _toggleFullScreen, + onClose: widget.onClose, + onBack: _hideLogDetail, + onCopy: () => _copyLogDetails(_selectedLog!), + ), + Expanded( + child: YxLogDetailPage( + log: _selectedLog!, + theme: widget.theme, + ), + ), + ], + ); + } else { + // 显示主列表页面 + final stats = widget.controller.getStatistics(); + content = Column( + children: [ + InspectorHeader( + theme: widget.theme, + isFullScreen: _isFullScreen, + onToggleFullScreen: _toggleFullScreen, + onClearLogs: widget.controller.clearLogs, + onCopyLogs: () { + final logs = widget.controller.getAllLogsAsString(); + Clipboard.setData(ClipboardData(text: logs)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Logs copied to clipboard'), + duration: Duration(milliseconds: 1500), + ), + ); + }, + onClose: widget.onClose, + ), + InspectorStatistics( + theme: widget.theme, + totalRequests: stats['totalRequests'] as int? ?? 0, + errorRequests: stats['errorRequests'] as int? ?? 0, + showOnlyErrors: _showOnlyErrors, + onToggleFilter: () { + setState(() { + _showOnlyErrors = !_showOnlyErrors; + }); + }, + ), + InspectorSearchBar( + theme: widget.theme, + controller: _searchController, + onChanged: (value) { + setState(() { + _searchKeyword = value; + }); + }, + ), + Expanded( + child: InspectorLogList( + logs: _filteredLogs, + theme: widget.theme, + onLogTap: _showLogDetail, + ), + ), + ], + ); + } + + if (_isFullScreen) { + // 全屏模式 - 考虑状态栏高度 + return Material( + color: Colors.black.withValues(alpha: 0.5), + child: SafeArea( + child: Container( + width: MediaQuery.of(context).size.width, + height: double.infinity, + color: widget.theme.backgroundColor, + child: content, + ), + ), + ); + } else { + // 对话框模式 + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + height: MediaQuery.of(context).size.height * 0.8, + decoration: BoxDecoration( + color: widget.theme.backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: content, + ), + ); + } + }, + ); } } diff --git a/lib/src/widgets/log_detail_page.dart b/lib/src/widgets/log_detail_page.dart index c18f735..a452430 100644 --- a/lib/src/widgets/log_detail_page.dart +++ b/lib/src/widgets/log_detail_page.dart @@ -1,18 +1,19 @@ import 'dart:convert'; + import 'package:flutter/material.dart'; -import '../models/network_log_entry.dart'; -import '../models/inspector_theme.dart'; +import 'package:flutter/services.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/models/network_log_entry.dart'; /// 网络日志详情页面 class YxLogDetailPage extends StatelessWidget { + const YxLogDetailPage({required this.log, required this.theme, super.key}); final NetworkLogEntry log; final YxNetInspectorTheme theme; - const YxLogDetailPage({super.key, required this.log, required this.theme}); - @override Widget build(BuildContext context) { - return Container( + return ColoredBox( color: theme.backgroundColor, child: SingleChildScrollView( padding: const EdgeInsets.all(16), @@ -21,10 +22,21 @@ class YxLogDetailPage extends StatelessWidget { children: [ _buildBasicInfoCard(context), const SizedBox(height: 16), + _buildCurlCard(context), + const SizedBox(height: 16), _buildRequestInfoCard(), const SizedBox(height: 16), + if (log.headers != null && log.headers!.isNotEmpty) ...[ + _buildHeadersCard('请求头 (Request Headers)', log.headers!), + const SizedBox(height: 16), + ], _buildResponseInfoCard(), const SizedBox(height: 16), + if (log.responseHeaders != null && + log.responseHeaders!.isNotEmpty) ...[ + _buildHeadersCard('响应头 (Response Headers)', log.responseHeaders!), + const SizedBox(height: 16), + ], if (log.errorMessage != null) ...[ _buildErrorInfoCard(), const SizedBox(height: 16), @@ -372,7 +384,7 @@ class YxLogDetailPage extends StatelessWidget { } else if (requestData is Map) { try { return const JsonEncoder.withIndent(' ').convert(requestData); - } catch (e) { + } on Object catch (_) { return requestData.toString(); } } else { @@ -381,7 +393,129 @@ class YxLogDetailPage extends StatelessWidget { } String _formatDateTime(DateTime dateTime) { - return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ' - '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}:${dateTime.second.toString().padLeft(2, '0')}'; + return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-' + '${dateTime.day.toString().padLeft(2, '0')} ' + '${dateTime.hour.toString().padLeft(2, '0')}:' + '${dateTime.minute.toString().padLeft(2, '0')}:' + '${dateTime.second.toString().padLeft(2, '0')}'; + } + + Widget _buildHeadersCard(String title, Map headers) { + return Card( + color: theme.cardColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.list_alt, color: theme.primaryColor), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.textColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 200), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.primaryColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(4), + ), + child: SingleChildScrollView( + child: SelectableText( + headers.entries.map((e) => '${e.key}: ${e.value}').join('\n'), + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: theme.textColor, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCurlCard(BuildContext context) { + final curlCommand = log.toCurlCommand(); + return Card( + color: theme.cardColor, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.terminal, color: theme.warningColor), + const SizedBox(width: 8), + Expanded( + child: Text( + 'cURL 命令', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: theme.textColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: Icon(Icons.copy, color: theme.primaryColor, size: 20), + tooltip: '复制 cURL', + onPressed: () { + Clipboard.setData(ClipboardData(text: curlCommand)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('cURL 已复制到剪贴板'), + duration: Duration(seconds: 2), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 150), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.warningColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView( + child: SelectableText( + curlCommand, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: theme.textColor, + ), + ), + ), + ), + ), + ], + ), + ), + ); } } diff --git a/lib/src/widgets/panel/inspector_detail_header.dart b/lib/src/widgets/panel/inspector_detail_header.dart new file mode 100644 index 0000000..a6e8175 --- /dev/null +++ b/lib/src/widgets/panel/inspector_detail_header.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; + +class InspectorDetailHeader extends StatelessWidget { + const InspectorDetailHeader({ + required this.theme, + required this.isFullScreen, + required this.onToggleFullScreen, + required this.onClose, + required this.onBack, + required this.onCopy, + super.key, + }); + + final YxNetInspectorTheme theme; + final bool isFullScreen; + final VoidCallback onToggleFullScreen; + final VoidCallback onClose; + final VoidCallback onBack; + final VoidCallback onCopy; + + @override + Widget build(BuildContext context) { + final actions = [ + IconButton( + onPressed: onCopy, + icon: const Icon( + Icons.copy, + color: Colors.white, + ), + tooltip: '复制详情', + ), + IconButton( + onPressed: onToggleFullScreen, + icon: Icon( + isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen, + color: Colors.white, + ), + tooltip: isFullScreen ? '退出全屏' : '全屏显示', + ), + IconButton( + onPressed: onClose, + icon: const Icon( + Icons.close, + color: Colors.white, + ), + tooltip: '关闭', + ), + ]; + + return isFullScreen + ? AppBar( + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + // excludeHeaderSemantics: false + leading: IconButton( + onPressed: onBack, + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + ), + centerTitle: false, + actions: actions, + ) + : Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.primaryColor, + borderRadius: isFullScreen + ? BorderRadius.zero + : const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + IconButton( + onPressed: onBack, + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + tooltip: '返回列表', + ), + const Text( + '请求详情', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + ...actions, + ], + ), + ); + } +} diff --git a/lib/src/widgets/panel/inspector_header.dart b/lib/src/widgets/panel/inspector_header.dart new file mode 100644 index 0000000..ee8c386 --- /dev/null +++ b/lib/src/widgets/panel/inspector_header.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; + +class InspectorHeader extends StatelessWidget { + const InspectorHeader({ + required this.theme, + required this.isFullScreen, + required this.onToggleFullScreen, + required this.onClearLogs, + required this.onClose, + this.onCopyLogs, + super.key, + }); + + final YxNetInspectorTheme theme; + final bool isFullScreen; + final VoidCallback onToggleFullScreen; + final VoidCallback onClearLogs; + final VoidCallback onClose; + final VoidCallback? onCopyLogs; + + @override + Widget build(BuildContext context) { + final actions = [ + IconButton( + onPressed: onToggleFullScreen, + icon: Icon( + isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen, + color: Colors.white, + ), + tooltip: isFullScreen ? '退出全屏' : '全屏', + ), + IconButton( + onPressed: onClearLogs, + icon: const Icon(Icons.clear_all, color: Colors.white), + tooltip: '清空日志', + ), + if (onCopyLogs != null) + IconButton( + onPressed: onCopyLogs, + icon: const Icon(Icons.copy, color: Colors.white), + tooltip: '复制所有日志', + ), + IconButton( + onPressed: onClose, + icon: const Icon(Icons.close, color: Colors.white), + ), + ]; + + return isFullScreen + ? AppBar( + title: const Text('网络检查器'), + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + centerTitle: false, + actions: actions, + ) + : Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + color: theme.primaryColor, + borderRadius: isFullScreen + ? BorderRadius.zero + : const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + ...actions, + ], + ), + ); + } +} diff --git a/lib/src/widgets/panel/inspector_log_list.dart b/lib/src/widgets/panel/inspector_log_list.dart new file mode 100644 index 0000000..6cfc697 --- /dev/null +++ b/lib/src/widgets/panel/inspector_log_list.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/models/network_log_entry.dart'; + +class InspectorLogList extends StatelessWidget { + const InspectorLogList({ + required this.logs, + required this.theme, + required this.onLogTap, + super.key, + }); + + final List logs; + final YxNetInspectorTheme theme; + final ValueChanged onLogTap; + + @override + Widget build(BuildContext context) { + if (logs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox, size: 64, color: theme.secondaryTextColor), + const SizedBox(height: 16), + Text( + '未找到网络请求', + style: TextStyle( + fontSize: 16, + color: theme.secondaryTextColor, + ), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: logs.length, + itemBuilder: (context, index) { + final log = logs[index]; + return InspectorLogItem( + log: log, + theme: theme, + onTap: () => onLogTap(log), + ); + }, + ); + } +} + +class InspectorLogItem extends StatelessWidget { + const InspectorLogItem({ + required this.log, + required this.theme, + required this.onTap, + super.key, + }); + + final NetworkLogEntry log; + final YxNetInspectorTheme theme; + final VoidCallback onTap; + + Color _getMethodColor(String method) { + switch (method.toUpperCase()) { + case 'GET': + return Colors.blue; + case 'POST': + return Colors.green; + case 'PUT': + return Colors.orange; + case 'DELETE': + return Colors.red; + case 'PATCH': + return Colors.purple; + default: + return theme.secondaryTextColor; + } + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: log.status == NetworkRequestStatus.failed + ? theme.errorColor.withValues(alpha: 0.3) + : Colors.transparent, + width: log.status == NetworkRequestStatus.failed ? 1 : 0, + ), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + // URL和状态 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + log.displayUrl, + style: TextStyle( + fontSize: 13, + color: theme.textColor, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: [ + // 状态指示器 + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: log.statusColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + + // 方法标签 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: _getMethodColor(log.method) + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + log.method, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: _getMethodColor(log.method), + ), + ), + ), + const SizedBox(width: 8), + + Text( + '${log.statusCode ?? "?"} ${log.formattedDuration}', + style: TextStyle( + fontSize: 11, + color: log.statusColor, + fontWeight: FontWeight.w500, + ), + ), + if (log.errorMessage != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.error_outline, + size: 12, + color: theme.errorColor, + ), + ], + const Spacer(), + // 时间 - Fix: Use split safely or standard formatting + Text( + log.formattedTime.contains(' ') + ? log.formattedTime.split(' ')[1] + : log.formattedTime, + style: TextStyle( + fontSize: 10, + color: theme.secondaryTextColor, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/panel/inspector_search_bar.dart b/lib/src/widgets/panel/inspector_search_bar.dart new file mode 100644 index 0000000..5dd3964 --- /dev/null +++ b/lib/src/widgets/panel/inspector_search_bar.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; + +class InspectorSearchBar extends StatelessWidget { + const InspectorSearchBar({ + required this.theme, + required this.controller, + required this.onChanged, + super.key, + }); + + final YxNetInspectorTheme theme; + final TextEditingController controller; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + style: TextStyle(color: theme.textColor), + decoration: InputDecoration( + hintText: '搜索请求...', + hintStyle: TextStyle(color: theme.secondaryTextColor), + prefixIcon: Icon( + Icons.search, + color: theme.secondaryTextColor, + ), + border: OutlineInputBorder( + borderSide: BorderSide(color: theme.borderColor), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + onChanged: onChanged, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/panel/inspector_statistics.dart b/lib/src/widgets/panel/inspector_statistics.dart new file mode 100644 index 0000000..7102bc5 --- /dev/null +++ b/lib/src/widgets/panel/inspector_statistics.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; + +class InspectorStatistics extends StatelessWidget { + const InspectorStatistics({ + required this.theme, + required this.totalRequests, + required this.errorRequests, + required this.showOnlyErrors, + required this.onToggleFilter, + super.key, + }); + + final YxNetInspectorTheme theme; + final int totalRequests; + final int errorRequests; + final bool showOnlyErrors; + final VoidCallback onToggleFilter; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: theme.backgroundColor, + border: Border( + bottom: BorderSide( + color: theme.secondaryTextColor.withValues(alpha: 0.2), + ), + ), + ), + child: Row( + children: [ + // 简化统计 - 只显示关键信息 + Text( + '总请求: $totalRequests', + style: TextStyle( + fontSize: 14, + color: theme.textColor, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 16), + if (errorRequests > 0) ...[ + Icon( + Icons.error_outline, + size: 16, + color: theme.errorColor, + ), + const SizedBox(width: 4), + Text( + '错误: $errorRequests', + style: TextStyle( + fontSize: 14, + color: theme.errorColor, + fontWeight: FontWeight.w600, + ), + ), + ] else ...[ + Icon( + Icons.check_circle_outline, + size: 16, + color: theme.successColor, + ), + const SizedBox(width: 4), + Text( + '全部成功', + style: TextStyle( + fontSize: 14, + color: theme.successColor, + fontWeight: FontWeight.w500, + ), + ), + ], + const Spacer(), + // 快速过滤按钮 + if (errorRequests > 0) + TextButton.icon( + onPressed: onToggleFilter, + icon: Icon( + showOnlyErrors ? Icons.clear : Icons.filter_list, + size: 16, + ), + label: Text(showOnlyErrors ? '显示全部' : '仅错误'), + style: TextButton.styleFrom( + foregroundColor: theme.errorColor, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/widgets/panel/password_dialog.dart b/lib/src/widgets/panel/password_dialog.dart new file mode 100644 index 0000000..b3b4087 --- /dev/null +++ b/lib/src/widgets/panel/password_dialog.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; + +/// 密码输入对话框 +class PasswordDialog extends StatefulWidget { + const PasswordDialog({ + required this.onUnlock, + required this.theme, + super.key, + }); + + /// 解锁回调 + final Future Function(String password) onUnlock; + + /// 主题 + final YxNetInspectorTheme theme; + + @override + State createState() => _PasswordDialogState(); +} + +class _PasswordDialogState extends State { + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _errorMessage; + bool _obscureText = true; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _handleUnlock() async { + final password = _passwordController.text; + if (password.isEmpty) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + final success = await widget.onUnlock(password); + + if (mounted) { + if (!success) { + setState(() { + _isLoading = false; + _errorMessage = '密码错误,请重试'; + }); + } + // If success, the dialog (or lock screen) will typically be removed by the parent + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: widget.theme.backgroundColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_outline, + size: 48, + color: widget.theme.primaryColor, + ), + const SizedBox(height: 16), + Text( + '安全访问', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: widget.theme.textColor, + ), + ), + const SizedBox(height: 8), + Text( + '请输入密码以查看网络日志', + style: TextStyle( + fontSize: 14, + color: widget.theme.secondaryTextColor, + ), + ), + const SizedBox(height: 24), + TextField( + controller: _passwordController, + obscureText: _obscureText, + decoration: InputDecoration( + hintText: '输入密码', + errorText: _errorMessage, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.vpn_key), + suffixIcon: IconButton( + icon: Icon( + _obscureText ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + onSubmitted: (_) => _handleUnlock(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleUnlock, + style: ElevatedButton.styleFrom( + backgroundColor: widget.theme.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('解锁'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/yx_net_inspector_app.dart b/lib/src/yx_net_inspector_app.dart index f59fd20..25ab75f 100644 --- a/lib/src/yx_net_inspector_app.dart +++ b/lib/src/yx_net_inspector_app.dart @@ -1,24 +1,15 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'controller/yx_net_inspector_controller.dart'; -import 'models/inspector_config.dart'; -import 'models/inspector_theme.dart'; -import 'widgets/floating_ball.dart'; +import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; +import 'package:yx_net_inspector/src/models/inspector_config.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/widgets/floating_ball.dart'; +import 'package:yx_net_inspector/src/widgets/inspector_panel.dart'; /// 包装你的应用并提供网络检查功能的主要组件 class YxNetInspector extends StatefulWidget { - /// 你的应用组件 - final Widget child; - - /// 检查器配置 - final YxNetInspectorConfig config; - - /// 检查器主题 - final YxNetInspectorTheme theme; - const YxNetInspector({ - super.key, required this.child, + super.key, this.config = const YxNetInspectorConfig(), this.theme = const YxNetInspectorTheme(), }); @@ -52,6 +43,15 @@ class YxNetInspector extends StatefulWidget { ); } + /// 你的应用组件 + final Widget child; + + /// 检查器配置 + final YxNetInspectorConfig config; + + /// 检查器主题 + final YxNetInspectorTheme theme; + @override State createState() => _YxNetInspectorState(); } @@ -97,6 +97,44 @@ class _YxNetInspectorState extends State { ); }, ), + + // 检查器面板覆盖层 + ListenableBuilder( + listenable: _controller, + builder: (context, child) { + if (!_controller.isPanelVisible) { + return const SizedBox.shrink(); + } + + return Overlay( + initialEntries: [ + OverlayEntry( + builder: (context) => Stack( + children: [ + // 背景遮罩 + GestureDetector( + onTap: _controller.hidePanel, + child: Container( + color: Colors.black54, + width: double.infinity, + height: double.infinity, + ), + ), + // 检查器面板 + Center( + child: YxInspectorPanel( + theme: widget.theme, + controller: _controller, + onClose: _controller.hidePanel, + ), + ), + ], + ), + ), + ], + ); + }, + ), ], ); } @@ -133,12 +171,14 @@ class YxNetInspectorGlobal { int? statusCode, dynamic responseData, Duration? duration, + Map? responseHeaders, }) { controller.logResponse( id: id, statusCode: statusCode, responseData: responseData, duration: duration, + responseHeaders: responseHeaders, ); } diff --git a/lib/yx_net_inspector.dart b/lib/yx_net_inspector.dart index 425ead8..22c6562 100644 --- a/lib/yx_net_inspector.dart +++ b/lib/yx_net_inspector.dart @@ -1,12 +1,11 @@ -library yx_net_inspector; - // 核心导出 -export 'src/yx_net_inspector_app.dart' show YxNetInspector; export 'src/controller/yx_net_inspector_controller.dart'; -export 'src/models/network_log_entry.dart'; +export 'src/interceptors/dio_interceptor.dart'; export 'src/models/inspector_config.dart'; export 'src/models/inspector_theme.dart'; +export 'src/models/network_log_entry.dart'; export 'src/widgets/floating_ball_config.dart'; +export 'src/yx_net_inspector_app.dart' show YxNetInspector; // Dio 拦截器需要单独导入: // import 'package:yx_net_inspector/src/interceptors/dio_interceptor.dart'; diff --git a/pubspec.lock b/pubspec.lock index c7dd4c9..668ddfa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" fake_async: dependency: transitive description: @@ -59,30 +75,38 @@ packages: description: flutter source: sdk version: "0.0.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.flutter-io.cn" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.1" + version: "3.0.2" matcher: dependency: transitive description: @@ -107,6 +131,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" path: dependency: transitive description: @@ -164,18 +196,34 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.4" + version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.4" + version: "2.2.0" + very_good_analysis: + dependency: "direct dev" + description: + name: very_good_analysis + sha256: "62d2b86d183fb81b2edc22913d9f155d26eb5cf3855173adb1f59fac85035c63" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.0" vm_service: dependency: transitive description: @@ -184,6 +232,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.0.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" sdks: - dart: ">=3.7.0-0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index a9e9b1c..ebcfc58 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: yx_net_inspector description: A powerful network inspector with floating debug ball for Flutter apps. Monitor HTTP requests, responses, and debug network issues in real-time. -version: 1.0.3 +version: 1.1.0 homepage: https://github.com/your-username/yx_net_inspector environment: @@ -10,9 +10,12 @@ environment: dependencies: flutter: sdk: flutter + dio: ^5.9.0 dev_dependencies: flutter_test: sdk: flutter + very_good_analysis: ^7.0.0 + diff --git a/test/integration/full_workflow_test.dart b/test/integration/full_workflow_test.dart index 7c450ee..7494b8e 100644 --- a/test/integration/full_workflow_test.dart +++ b/test/integration/full_workflow_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:yx_net_inspector/yx_net_inspector.dart'; import 'package:yx_net_inspector/src/widgets/floating_ball.dart'; +import 'package:yx_net_inspector/yx_net_inspector.dart'; void main() { group('YX Net Inspector 集成测试', () { @@ -9,12 +9,6 @@ void main() { // 创建测试应用 await tester.pumpWidget( YxNetInspector( - config: const YxNetInspectorConfig( - showFloatingBall: true, - ballSize: 60.0, - showInDebugMode: true, - ), - theme: const YxNetInspectorTheme(), child: MaterialApp( home: Scaffold( appBar: AppBar(title: const Text('测试应用')), @@ -52,8 +46,8 @@ void main() { statusCode: 200, responseData: { 'users': [ - {'id': 1, 'name': 'John'} - ] + {'id': 1, 'name': 'John'}, + ], }, duration: const Duration(milliseconds: 300), ); @@ -146,10 +140,6 @@ void main() { testWidgets('悬浮球拖拽和位置测试', (WidgetTester tester) async { await tester.pumpWidget( YxNetInspector( - config: const YxNetInspectorConfig( - showFloatingBall: true, - draggable: true, - ), child: MaterialApp( home: Scaffold( body: Container(), @@ -179,13 +169,13 @@ void main() { testWidgets('配置禁用时不显示悬浮球', (WidgetTester tester) async { await tester.pumpWidget( - YxNetInspector( - config: const YxNetInspectorConfig( + const YxNetInspector( + config: YxNetInspectorConfig( showFloatingBall: false, ), child: MaterialApp( home: Scaffold( - body: const Text('Test App'), + body: Text('Test App'), ), ), ), @@ -203,7 +193,6 @@ void main() { testWidgets('主题配置测试', (WidgetTester tester) async { await tester.pumpWidget( YxNetInspector( - config: const YxNetInspectorConfig(), theme: const YxNetInspectorTheme( primaryColor: Colors.purple, backgroundColor: Colors.black, @@ -222,7 +211,7 @@ void main() { // 添加一些日志 final controller = YxNetInspectorController.instance; controller.logRequest( - id: 'test', method: 'GET', url: 'https://example.com'); + id: 'test', method: 'GET', url: 'https://example.com',); await tester.pump(); @@ -253,7 +242,7 @@ void main() { controller.clearLogs(); // 添加大量日志 - for (int i = 0; i < 150; i++) { + for (var i = 0; i < 150; i++) { controller.logRequest( id: 'test-$i', method: 'GET', @@ -302,7 +291,7 @@ void main() { controller.clearLogs(); // 添加超过限制的日志 - for (int i = 0; i < 20; i++) { + for (var i = 0; i < 20; i++) { controller.logRequest( id: 'memory-test-$i', method: 'GET', diff --git a/test/test_all.dart b/test/test_all.dart index 1917cda..cb67bd8 100644 --- a/test/test_all.dart +++ b/test/test_all.dart @@ -1,18 +1,16 @@ // 测试入口文件 - 运行所有测试 import 'package:flutter_test/flutter_test.dart'; +// 集成测试 +import 'integration/full_workflow_test.dart' as integration_test; +import 'unit/inspector_config_test.dart' as config_test; // 单元测试 import 'unit/network_log_entry_test.dart' as network_log_entry_test; import 'unit/yx_net_inspector_controller_test.dart' as controller_test; -import 'unit/inspector_config_test.dart' as config_test; - // Widget测试 import 'widget/floating_ball_test.dart' as floating_ball_test; import 'widget/inspector_panel_test.dart' as inspector_panel_test; -// 集成测试 -import 'integration/full_workflow_test.dart' as integration_test; - void main() { group('YX Net Inspector 完整测试套件', () { group('单元测试', () { @@ -26,8 +24,6 @@ void main() { inspector_panel_test.main(); }); - group('集成测试', () { - integration_test.main(); - }); + group('集成测试', integration_test.main); }); } diff --git a/test/unit/dio_interceptor_test.dart b/test/unit/dio_interceptor_test.dart new file mode 100644 index 0000000..cae36f0 --- /dev/null +++ b/test/unit/dio_interceptor_test.dart @@ -0,0 +1,102 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; +import 'package:yx_net_inspector/src/interceptors/dio_interceptor.dart'; +import 'package:yx_net_inspector/src/models/inspector_config.dart'; + +void main() { + group('YxNetInspectorDioInterceptor Tests', () { + late YxNetInspectorController controller; + late Dio dio; + + setUp(() { + controller = YxNetInspectorController.instance; + // Initialize controller with logging enabled (debug mode) + controller.initialize(const YxNetInspectorConfig()); + + dio = Dio(); + // Add our inspector interceptor FIRST + dio.interceptors.add(YxNetInspectorDioInterceptor()); + }); + + test('Should log request and response upon success', () async { + final initialLogCount = controller.logs.length; + + // Add a mock interceptor to return success response immediately + dio.interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + // Allow request to proceed (so our inspector sees it) + handler.next(options); + }, + onError: (e, handler) => handler.next(e), + onResponse: (e, handler) => handler.next(e), + ), + ); + + // Mock Adapter to avoid real network + dio.httpClientAdapter = _MockAdapter((options) { + return ResponseBody.fromString( + '{"message": "success"}', + 200, + headers: { + Headers.contentTypeHeader: [Headers.jsonContentType], + }, + ); + }); + + await dio.get('https://example.com/api/test'); + + expect(controller.logs.length, greaterThan(initialLogCount)); + final latestLog = controller.logs.first; + + expect(latestLog.url, 'https://example.com/api/test'); + expect(latestLog.method, 'GET'); + expect(latestLog.statusCode, 200); + }); + + test('Should log error upon failure', () async { + final initialLogCount = controller.logs.length; + + // Mock Adapter to throw error + dio.httpClientAdapter = _MockAdapter((options) { + throw DioException( + requestOptions: options, + error: 'Network Error', + type: DioExceptionType.connectionError, + ); + }); + + try { + await dio.post('https://example.com/api/fail', + data: {'foo': 'bar'},); + } on Object catch (_) { + // Expected + } + + expect(controller.logs.length, greaterThan(initialLogCount)); + final latestLog = controller.logs.first; + + expect(latestLog.url, 'https://example.com/api/fail'); + expect(latestLog.method, 'POST'); + expect(latestLog.isSuccess, false); + }); + }); +} + +class _MockAdapter implements HttpClientAdapter { + _MockAdapter(this._mockResponse); + final ResponseBody Function(RequestOptions options) _mockResponse; + + @override + Future fetch( + RequestOptions options, + Stream>? requestStream, + Future? cancelFuture, + ) async { + return _mockResponse(options); + } + + @override + void close({bool force = false}) {} +} diff --git a/test/unit/inspector_config_test.dart b/test/unit/inspector_config_test.dart index a7a59bc..560e2a6 100644 --- a/test/unit/inspector_config_test.dart +++ b/test/unit/inspector_config_test.dart @@ -23,7 +23,7 @@ void main() { test('应该允许自定义配置', () { const config = YxNetInspectorConfig( showFloatingBall: false, - ballSize: 80.0, + ballSize: 80, ballColor: Colors.red, showInDebugMode: false, showInReleaseMode: true, @@ -51,8 +51,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; const configEnabledInDebug = YxNetInspectorConfig( - showInDebugMode: true, - showInReleaseMode: false, + ); const configDisabledInDebug = YxNetInspectorConfig( @@ -75,27 +74,24 @@ void main() { test('copyWith 应该正确创建副本', () { const originalConfig = YxNetInspectorConfig( - showFloatingBall: true, - ballSize: 60.0, - maxLogs: 1000, + ); final copiedConfig = originalConfig.copyWith( showFloatingBall: false, - ballSize: 80.0, + ballSize: 80, ); expect(copiedConfig.showFloatingBall, isFalse); expect(copiedConfig.ballSize, equals(80.0)); expect(copiedConfig.maxLogs, equals(1000)); // 未更改的值应该保持原样 expect( - copiedConfig.showInDebugMode, equals(originalConfig.showInDebugMode)); + copiedConfig.showInDebugMode, equals(originalConfig.showInDebugMode),); }); test('toString 应该返回有用的字符串表示', () { const config = YxNetInspectorConfig( - showFloatingBall: true, - ballSize: 70.0, + ballSize: 70, ); final string = config.toString(); @@ -106,21 +102,15 @@ void main() { test('相等性比较应该正确工作', () { const config1 = YxNetInspectorConfig( - showFloatingBall: true, - ballSize: 60.0, - maxLogs: 1000, + ); const config2 = YxNetInspectorConfig( - showFloatingBall: true, - ballSize: 60.0, - maxLogs: 1000, + ); const config3 = YxNetInspectorConfig( showFloatingBall: false, // 不同的值 - ballSize: 60.0, - maxLogs: 1000, ); expect(config1, equals(config2)); @@ -130,9 +120,8 @@ void main() { }); test('空值参数应该正确处理', () { - final config = YxNetInspectorConfig().copyWith( - ballColor: null, - initialPosition: null, + final config = const YxNetInspectorConfig().copyWith( + ); expect(config.ballColor, isNull); @@ -141,7 +130,7 @@ void main() { test('边界值应该正确处理', () { const config = YxNetInspectorConfig( - ballSize: 0.0, // 最小值 + ballSize: 0, // 最小值 maxLogs: 1, // 最小日志数 ); diff --git a/test/unit/network_log_entry_test.dart b/test/unit/network_log_entry_test.dart index c0769c9..28122df 100644 --- a/test/unit/network_log_entry_test.dart +++ b/test/unit/network_log_entry_test.dart @@ -1,5 +1,5 @@ -import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:yx_net_inspector/src/models/network_log_entry.dart'; void main() { @@ -12,12 +12,11 @@ void main() { id: 'test-1', method: 'GET', url: 'https://api.example.com/users?page=1&limit=20', - headers: {'Authorization': 'Bearer token'}, - requestData: null, - queryParameters: {'page': '1', 'limit': '20'}, + headers: const {'Authorization': 'Bearer token'}, + queryParameters: const {'page': '1', 'limit': '20'}, statusCode: 200, - responseData: {'users': []}, - timestamp: DateTime(2024, 1, 1, 12, 0, 0), + responseData: const {'users': []}, + timestamp: DateTime(2024, 1, 1, 12), duration: const Duration(milliseconds: 500), isSuccess: true, ); @@ -26,11 +25,11 @@ void main() { id: 'test-2', method: 'POST', url: 'https://api.example.com/users', - headers: {'Content-Type': 'application/json'}, - requestData: {'name': 'Test User'}, + headers: const {'Content-Type': 'application/json'}, + requestData: const {'name': 'Test User'}, statusCode: 404, errorMessage: '用户不存在', - timestamp: DateTime(2024, 1, 1, 12, 5, 0), + timestamp: DateTime(2024, 1, 1, 12, 5), duration: const Duration(milliseconds: 1500), isSuccess: false, ); @@ -65,7 +64,7 @@ void main() { test('requestSize 应该计算正确的请求大小', () { expect(successfulEntry.requestSize, greaterThan(0)); expect(errorEntry.requestSize, - greaterThanOrEqualTo(successfulEntry.requestSize)); + greaterThanOrEqualTo(successfulEntry.requestSize),); }); test('responseSize 应该计算正确的响应大小', () { diff --git a/test/unit/yx_net_inspector_controller_test.dart b/test/unit/yx_net_inspector_controller_test.dart index 48602db..910aa4e 100644 --- a/test/unit/yx_net_inspector_controller_test.dart +++ b/test/unit/yx_net_inspector_controller_test.dart @@ -22,7 +22,7 @@ void main() { }); test('初始化后应该有正确的默认值', () { - final config = YxNetInspectorConfig(); + const config = YxNetInspectorConfig(); controller.initialize(config); expect(controller.logs, isEmpty); @@ -33,7 +33,7 @@ void main() { }); test('logRequest 应该正确添加请求日志', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); controller.logRequest( id: 'test-1', @@ -50,7 +50,7 @@ void main() { }); test('logResponse 应该正确更新请求日志', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); // 先添加请求 controller.logRequest( @@ -72,11 +72,11 @@ void main() { expect(controller.logs.first.statusCode, equals(200)); expect(controller.logs.first.isSuccess, isTrue); expect(controller.logs.first.duration, - equals(const Duration(milliseconds: 500))); + equals(const Duration(milliseconds: 500)),); }); test('logError 应该正确处理错误日志', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); // 先添加请求 controller.logRequest( @@ -101,11 +101,11 @@ void main() { }); test('clearLogs 应该清空所有日志和统计', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); // 添加一些日志 controller.logRequest( - id: 'test-1', method: 'GET', url: 'https://example.com'); + id: 'test-1', method: 'GET', url: 'https://example.com',); controller.logResponse(id: 'test-1', statusCode: 200); expect(controller.logs, isNotEmpty); @@ -122,7 +122,7 @@ void main() { }); test('悬浮球显示状态应该正确切换', () { - controller.initialize(YxNetInspectorConfig(showFloatingBall: true)); + controller.initialize(const YxNetInspectorConfig()); expect(controller.showFloatingBall, isTrue); @@ -137,23 +137,23 @@ void main() { }); test('getStatistics 应该返回正确的统计信息', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); // 添加成功请求 controller.logRequest( - id: 'success', method: 'GET', url: 'https://example.com'); + id: 'success', method: 'GET', url: 'https://example.com',); controller.logResponse( id: 'success', statusCode: 200, - duration: const Duration(milliseconds: 300)); + duration: const Duration(milliseconds: 300),); // 添加失败请求 controller.logRequest( - id: 'error', method: 'POST', url: 'https://example.com'); + id: 'error', method: 'POST', url: 'https://example.com',); controller.logError( id: 'error', error: '错误', - duration: const Duration(milliseconds: 500)); + duration: const Duration(milliseconds: 500),); final stats = controller.getStatistics(); @@ -165,12 +165,12 @@ void main() { }); test('getRecentLogs 应该返回指定数量的最近日志', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); // 添加多个日志 - for (int i = 0; i < 10; i++) { + for (var i = 0; i < 10; i++) { controller.logRequest( - id: 'test-$i', method: 'GET', url: 'https://example.com/$i'); + id: 'test-$i', method: 'GET', url: 'https://example.com/$i',); } final recentLogs = controller.getRecentLogs(count: 5); @@ -179,15 +179,15 @@ void main() { }); test('getLogsByStatus 应该正确过滤日志', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); // 添加成功和失败的请求 controller.logRequest( - id: 'success', method: 'GET', url: 'https://example.com'); + id: 'success', method: 'GET', url: 'https://example.com',); controller.logResponse(id: 'success', statusCode: 200); controller.logRequest( - id: 'error', method: 'POST', url: 'https://example.com'); + id: 'error', method: 'POST', url: 'https://example.com',); controller.logError(id: 'error', error: '错误'); final successLogs = controller.getLogsByStatus(isSuccess: true); @@ -200,16 +200,16 @@ void main() { }); test('searchLogs 应该正确搜索日志', () { - controller.initialize(YxNetInspectorConfig()); + controller.initialize(const YxNetInspectorConfig()); controller.logRequest( - id: 'user-req', method: 'GET', url: 'https://api.example.com/users'); + id: 'user-req', method: 'GET', url: 'https://api.example.com/users',); controller.logRequest( - id: 'post-req', method: 'POST', url: 'https://api.example.com/posts'); + id: 'post-req', method: 'POST', url: 'https://api.example.com/posts',); controller.logRequest( id: 'error-req', method: 'DELETE', - url: 'https://api.example.com/user/1'); + url: 'https://api.example.com/user/1',); controller.logError(id: 'error-req', error: '用户不存在'); final userLogs = controller.searchLogs('user'); @@ -222,12 +222,12 @@ void main() { }); test('日志数量应该受到maxLogs限制', () { - controller.initialize(YxNetInspectorConfig(maxLogs: 5)); + controller.initialize(const YxNetInspectorConfig(maxLogs: 5)); // 添加超过限制的日志 - for (int i = 0; i < 10; i++) { + for (var i = 0; i < 10; i++) { controller.logRequest( - id: 'test-$i', method: 'GET', url: 'https://example.com/$i'); + id: 'test-$i', method: 'GET', url: 'https://example.com/$i',); } expect(controller.logs.length, equals(5)); @@ -236,13 +236,12 @@ void main() { }); test('配置未启用时不应该记录日志', () { - controller.initialize(YxNetInspectorConfig( + controller.initialize(const YxNetInspectorConfig( showInDebugMode: false, - showInReleaseMode: false, - )); + ),); controller.logRequest( - id: 'test', method: 'GET', url: 'https://example.com'); + id: 'test', method: 'GET', url: 'https://example.com',); expect(controller.logs, isEmpty); expect(controller.requestCount, equals(0)); diff --git a/test/widget/floating_ball_test.dart b/test/widget/floating_ball_test.dart index 7b51d4e..b3a5b36 100644 --- a/test/widget/floating_ball_test.dart +++ b/test/widget/floating_ball_test.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:yx_net_inspector/src/widgets/floating_ball.dart'; import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; import 'package:yx_net_inspector/src/models/inspector_config.dart'; import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/widgets/floating_ball.dart'; void main() { group('YxFloatingBall Widget Tests', () { @@ -61,9 +61,9 @@ void main() { testWidgets('应该显示请求数量徽章', (WidgetTester tester) async { // 添加一些请求 controller.logRequest( - id: 'test-1', method: 'GET', url: 'https://example.com'); + id: 'test-1', method: 'GET', url: 'https://example.com',); controller.logRequest( - id: 'test-2', method: 'POST', url: 'https://example.com'); + id: 'test-2', method: 'POST', url: 'https://example.com',); await tester.pumpWidget( MaterialApp( @@ -86,7 +86,7 @@ void main() { testWidgets('应该显示错误数量徽章', (WidgetTester tester) async { // 添加请求和错误 controller.logRequest( - id: 'test-1', method: 'GET', url: 'https://example.com'); + id: 'test-1', method: 'GET', url: 'https://example.com',); controller.logError(id: 'test-1', error: '网络错误'); await tester.pumpWidget( @@ -181,7 +181,7 @@ void main() { // 添加一些请求 controller.logRequest( - id: 'test-1', method: 'GET', url: 'https://example.com'); + id: 'test-1', method: 'GET', url: 'https://example.com',); await tester.pumpWidget( MaterialApp( @@ -221,7 +221,7 @@ void main() { }); testWidgets('自定义大小应该正确应用', (WidgetTester tester) async { - final customSizeConfig = config.copyWith(ballSize: 80.0); + final customSizeConfig = config.copyWith(ballSize: 80); await tester.pumpWidget( MaterialApp( diff --git a/test/widget/inspector_panel_test.dart b/test/widget/inspector_panel_test.dart index 918b23f..49539e5 100644 --- a/test/widget/inspector_panel_test.dart +++ b/test/widget/inspector_panel_test.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:yx_net_inspector/src/widgets/inspector_panel.dart'; import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; import 'package:yx_net_inspector/src/models/inspector_config.dart'; import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/widgets/inspector_panel.dart'; +import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart'; void main() { group('YxInspectorPanel Widget Tests', () { @@ -34,20 +35,25 @@ void main() { ), ); - // 验证面板基本组件 - expect(find.text('网络检查器'), findsOneWidget); - expect(find.byIcon(Icons.network_check), findsOneWidget); + // 验证面板基本组件 (标题在非全屏模式已移除以节省空间) + // Title removed from non-fullscreen mode to save space expect(find.byIcon(Icons.close), findsOneWidget); }); testWidgets('应该显示统计信息', (WidgetTester tester) async { // 添加一些测试数据 controller.logRequest( - id: 'test-1', method: 'GET', url: 'https://example.com'); + id: 'test-1', + method: 'GET', + url: 'https://example.com', + ); controller.logResponse(id: 'test-1', statusCode: 200); controller.logRequest( - id: 'test-2', method: 'POST', url: 'https://example.com'); + id: 'test-2', + method: 'POST', + url: 'https://example.com', + ); controller.logError(id: 'test-2', error: '网络错误'); await tester.pumpWidget( @@ -65,12 +71,15 @@ void main() { await tester.pump(); // 验证统计信息 - expect(find.text('总计'), findsOneWidget); - expect(find.text('成功'), findsOneWidget); - expect(find.text('失败'), findsOneWidget); - expect(find.text('成功率'), findsOneWidget); - expect(find.text('2'), findsOneWidget); // 总请求数 - expect(find.text('1'), findsWidgets); // 成功和失败各1个 + // 验证统计信息 + expect(find.text('总请求: 2'), findsOneWidget); + expect(find.text('错误: 1'), findsOneWidget); + + // Removed old fields + // expect(find.text('总计'), findsOneWidget); + // expect(find.text('成功'), findsOneWidget); + // expect(find.text('失败'), findsOneWidget); + // expect(find.text('成功率'), findsOneWidget); }); testWidgets('应该显示搜索栏', (WidgetTester tester) async { @@ -89,16 +98,24 @@ void main() { // 验证搜索相关组件 expect(find.byType(TextField), findsOneWidget); expect(find.text('搜索请求...'), findsOneWidget); - expect(find.byType(FilterChip), findsOneWidget); - expect(find.text('仅显示错误'), findsOneWidget); + expect(find.text('搜索请求...'), findsOneWidget); + // FilterChip was removed/moved + // expect(find.byType(FilterChip), findsOneWidget); + // expect(find.text('仅显示错误'), findsOneWidget); }); testWidgets('搜索功能应该正常工作', (WidgetTester tester) async { // 添加测试数据 controller.logRequest( - id: 'user-1', method: 'GET', url: 'https://api.example.com/users'); + id: 'user-1', + method: 'GET', + url: 'https://api.example.com/users', + ); controller.logRequest( - id: 'post-1', method: 'POST', url: 'https://api.example.com/posts'); + id: 'post-1', + method: 'POST', + url: 'https://api.example.com/posts', + ); await tester.pumpWidget( MaterialApp( @@ -125,11 +142,17 @@ void main() { testWidgets('错误过滤器应该正常工作', (WidgetTester tester) async { // 添加成功和失败的请求 controller.logRequest( - id: 'success', method: 'GET', url: 'https://example.com'); + id: 'success', + method: 'GET', + url: 'https://example.com', + ); controller.logResponse(id: 'success', statusCode: 200); controller.logRequest( - id: 'error', method: 'POST', url: 'https://example.com'); + id: 'error', + method: 'POST', + url: 'https://example.com', + ); controller.logError(id: 'error', error: '网络错误'); await tester.pumpWidget( @@ -146,19 +169,21 @@ void main() { await tester.pump(); - // 点击错误过滤器 - await tester.tap(find.byType(FilterChip)); + // 点击错误过滤器 (It's a TextButton now) + await tester.tap(find.text('仅错误')); await tester.pump(); - // 验证过滤器被激活 - final filterChip = tester.widget(find.byType(FilterChip)); - expect(filterChip.selected, isTrue); + // 验证过滤器被激活 - Text changes to "显示全部" + expect(find.text('显示全部'), findsOneWidget); }); testWidgets('清空日志按钮应该正常工作', (WidgetTester tester) async { // 添加一些日志 controller.logRequest( - id: 'test', method: 'GET', url: 'https://example.com'); + id: 'test', + method: 'GET', + url: 'https://example.com', + ); await tester.pumpWidget( MaterialApp( @@ -204,7 +229,7 @@ void main() { }); testWidgets('关闭按钮应该调用回调', (WidgetTester tester) async { - bool closeCalled = false; + var closeCalled = false; await tester.pumpWidget( MaterialApp( @@ -272,7 +297,7 @@ void main() { await tester.pump(); // 验证日志条目显示 - expect(find.byType(Card), findsWidgets); + expect(find.byType(InspectorLogItem), findsWidgets); expect(find.text('GET'), findsOneWidget); }); @@ -300,7 +325,7 @@ void main() { await tester.pump(); // 点击日志条目 - await tester.tap(find.byType(InkWell).first); + await tester.tap(find.byType(InspectorLogItem).first); await tester.pumpAndSettle(); // 验证导航到详情页(这里主要验证没有报错) diff --git a/test/widget/password_protection_test.dart b/test/widget/password_protection_test.dart new file mode 100644 index 0000000..c9051aa --- /dev/null +++ b/test/widget/password_protection_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yx_net_inspector/src/controller/yx_net_inspector_controller.dart'; +import 'package:yx_net_inspector/src/models/inspector_config.dart'; +import 'package:yx_net_inspector/src/models/inspector_theme.dart'; +import 'package:yx_net_inspector/src/widgets/inspector_panel.dart'; +import 'package:yx_net_inspector/src/widgets/panel/password_dialog.dart'; + +void main() { + group('Password Protection Tests', () { + late YxNetInspectorController controller; + const theme = YxNetInspectorTheme(); + + setUp(() { + controller = YxNetInspectorController.instance; + }); + + testWidgets('Panel should be unlocked by default (no password)', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig()); + + expect(controller.isUnlocked, isTrue); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + expect(find.byType(PasswordDialog), findsNothing); + expect(find.byIcon(Icons.close), findsOneWidget); // Normal panel content + }); + + testWidgets('Panel should be locked if password is set', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig( + password: '123', + ),); + + expect(controller.isUnlocked, isFalse); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + expect(find.byType(PasswordDialog), findsOneWidget); + expect(find.text('安全访问'), findsOneWidget); + expect( + find.byIcon(Icons.close), findsNothing,); // Should hide panel content + }); + + testWidgets('Entering correct password should unlock panel', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig( + password: '123', + ),); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + // Enter correct password + await tester.enterText(find.byType(TextField), '123'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); // Start async unlock + await tester.pump(); // Rebuild with unlocked state + + expect(controller.isUnlocked, isTrue); + expect(find.byType(PasswordDialog), findsNothing); + expect(find.byIcon(Icons.close), findsOneWidget); + }); + + testWidgets('Entering wrong password should show error', + (WidgetTester tester) async { + controller.initialize(const YxNetInspectorConfig( + password: '123', + ),); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: YxInspectorPanel( + theme: theme, + controller: controller, + onClose: () {}, + ), + ), + ), + ); + + // Enter wrong password + await tester.enterText(find.byType(TextField), '000'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); // Start async unlock + await tester.pump(); // Rebuild + + expect(controller.isUnlocked, isFalse); + expect(find.byType(PasswordDialog), findsOneWidget); + expect(find.text('密码错误,请重试'), findsOneWidget); + }); + }); +}