Merge branch 'release/1.1.0'

This commit is contained in:
YuanXuan 2026-01-20 17:04:55 +08:00
commit 9d6aaf64b4
33 changed files with 1825 additions and 969 deletions

3
.gitignore vendored
View File

@ -107,3 +107,6 @@ temp/
# OS specific
.DS_Store
Thumbs.db
# macOS metadata files
._*

View File

@ -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
### 新增

13
analysis_options.yaml Normal file
View File

@ -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

View File

@ -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),
);

View File

@ -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"

View File

@ -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<NetworkLogEntry> _logs = <NetworkLogEntry>[];
List<NetworkLogEntry> 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<NetworkLogEntry> get logs => List.unmodifiable(_logs);
///
bool get showFloatingBall => _showFloatingBall;
///
bool get isPanelVisible => _isPanelVisible;
///
bool get isUnlocked => _isUnlocked;
///
///
Future<bool> 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<String, dynamic>? 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) {

View File

@ -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<dynamic> 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);
}
}

View File

@ -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,
);
}
}

View File

@ -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,

View File

@ -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<String, dynamic>? headers;
final Map<String, dynamic>? responseHeaders;
final dynamic requestData;
final Map<String, dynamic>? 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<String, dynamic>? headers,
Map<String, dynamic>? responseHeaders,
dynamic requestData,
Map<String, dynamic>? 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,

View File

@ -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<YxFloatingBall> createState() => _YxFloatingBallState();
}
@ -28,8 +23,6 @@ class _YxFloatingBallState extends State<YxFloatingBall>
late Animation<double> _opacityAnimation;
Offset _position = const Offset(20, 200);
bool _isExpanded = false;
OverlayEntry? _currentOverlayEntry;
@override
void initState() {
@ -46,167 +39,23 @@ class _YxFloatingBallState extends State<YxFloatingBall>
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
_scaleAnimation = Tween<double>(begin: 1, end: 1.2).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
_opacityAnimation = Tween<double>(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) {

View File

@ -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,

View File

@ -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<YxInspectorPanel> createState() => _YxInspectorPanelState();
@ -52,8 +57,14 @@ class _YxInspectorPanelState extends State<YxInspectorPanel> {
});
}
void _toggleFullScreen() {
setState(() {
_isFullScreen = !_isFullScreen;
});
}
List<NetworkLogEntry> get _filteredLogs {
List<NetworkLogEntry> logs = widget.controller.logs;
var logs = widget.controller.logs;
//
if (_searchKeyword.isNotEmpty) {
@ -70,497 +81,8 @@ class _YxInspectorPanelState extends State<YxInspectorPanel> {
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,
),
);
}
},
);
}
}

View File

@ -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<String, dynamic> 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,
),
),
),
),
),
],
),
),
);
}
}

View File

@ -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,
],
),
);
}
}

View File

@ -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,
],
),
);
}
}

View File

@ -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<NetworkLogEntry> logs;
final YxNetInspectorTheme theme;
final ValueChanged<NetworkLogEntry> 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,
),
),
],
),
],
),
),
],
),
),
),
);
}
}

View File

@ -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<String> 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,
),
),
],
),
);
}
}

View File

@ -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,
),
),
],
),
);
}
}

View File

@ -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<bool> Function(String password) onUnlock;
///
final YxNetInspectorTheme theme;
@override
State<PasswordDialog> createState() => _PasswordDialogState();
}
class _PasswordDialogState extends State<PasswordDialog> {
final _passwordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
bool _obscureText = true;
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
Future<void> _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<Color>(Colors.white),
),
)
: const Text('解锁'),
),
),
],
),
);
}
}

View File

@ -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<YxNetInspector> createState() => _YxNetInspectorState();
}
@ -97,6 +97,44 @@ class _YxNetInspectorState extends State<YxNetInspector> {
);
},
),
//
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<String, dynamic>? responseHeaders,
}) {
controller.logResponse(
id: id,
statusCode: statusCode,
responseData: responseData,
duration: duration,
responseHeaders: responseHeaders,
);
}

View File

@ -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';

View File

@ -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"

View File

@ -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

View File

@ -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',

View File

@ -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);
});
}

View File

@ -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<dynamic>('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<dynamic>('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<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) async {
return _mockResponse(options);
}
@override
void close({bool force = false}) {}
}

View File

@ -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, //
);

View File

@ -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 应该计算正确的响应大小', () {

View File

@ -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));

View File

@ -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(

View File

@ -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<FilterChip>(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();
//

View File

@ -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);
});
});
}