Compare commits

..

2 Commits

Author SHA1 Message Date
YuanXuan bcca8432f9 style: fix lint issues 2026-01-20 17:01:28 +08:00
YuanXuan af369c7622 chore: release 1.1.0 with password protection, built-in interceptor, and cURL support 2026-01-20 16:56:11 +08:00
18 changed files with 866 additions and 49 deletions

View File

@ -5,6 +5,19 @@
格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 格式基于 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
此项目遵循 [语义化版本](https://semver.org/spec/v2.0.0.html) 规范。 此项目遵循 [语义化版本](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 ## [1.0.0] - 2024-12-20
### 新增 ### 新增

View File

@ -41,6 +41,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.19.1" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -139,6 +155,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.16.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -238,7 +262,7 @@ packages:
path: ".." path: ".."
relative: true relative: true
source: path source: path
version: "1.0.3" version: "1.0.4"
sdks: sdks:
dart: ">=3.8.0-0 <4.0.0" dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@ -14,11 +14,9 @@ class YxNetInspectorController extends ChangeNotifier {
/// ///
late YxNetInspectorConfig _config; late YxNetInspectorConfig _config;
YxNetInspectorConfig get config => _config;
/// ///
final List<NetworkLogEntry> _logs = <NetworkLogEntry>[]; final List<NetworkLogEntry> _logs = <NetworkLogEntry>[];
List<NetworkLogEntry> get logs => List.unmodifiable(_logs);
/// ///
int _requestCount = 0; int _requestCount = 0;
@ -33,16 +31,54 @@ class YxNetInspectorController extends ChangeNotifier {
/// ///
bool _showFloatingBall = true; bool _showFloatingBall = true;
bool get showFloatingBall => _showFloatingBall && _config.isEnabled;
/// ///
bool _isPanelVisible = false; bool _isPanelVisible = false;
bool get isPanelVisible => _isPanelVisible && _config.isEnabled; bool _isUnlocked = true; //
/// ///
void initialize(YxNetInspectorConfig config) { void initialize(YxNetInspectorConfig config) {
_config = config; _config = config;
_showFloatingBall = config.showFloatingBall; _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();
}
} }
/// ///
@ -97,6 +133,7 @@ class YxNetInspectorController extends ChangeNotifier {
int? statusCode, int? statusCode,
dynamic responseData, dynamic responseData,
Duration? duration, Duration? duration,
Map<String, dynamic>? responseHeaders,
}) { }) {
if (!_config.isEnabled) return; if (!_config.isEnabled) return;
@ -110,6 +147,7 @@ class YxNetInspectorController extends ChangeNotifier {
final updatedLog = originalLog.copyWith( final updatedLog = originalLog.copyWith(
statusCode: statusCode, statusCode: statusCode,
responseData: responseData, responseData: responseData,
responseHeaders: responseHeaders,
duration: duration, duration: duration,
isSuccess: isSuccess, isSuccess: isSuccess,
status: isSuccess status: isSuccess
@ -235,6 +273,43 @@ class YxNetInspectorController extends ChangeNotifier {
}).toList(); }).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() { void _trimLogs() {
if (_logs.length > _config.maxLogs) { 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

@ -15,6 +15,7 @@ class YxNetInspectorConfig {
this.draggable = true, this.draggable = true,
this.showBadge = true, this.showBadge = true,
this.autoHide = false, this.autoHide = false,
this.password,
}); });
/// ///
@ -47,6 +48,9 @@ class YxNetInspectorConfig {
/// ///
final bool autoHide; final bool autoHide;
/// 访
final String? password;
/// ///
bool get isEnabled { bool get isEnabled {
if (kDebugMode) { if (kDebugMode) {
@ -68,6 +72,7 @@ class YxNetInspectorConfig {
bool? draggable, bool? draggable,
bool? showBadge, bool? showBadge,
bool? autoHide, bool? autoHide,
String? password,
}) { }) {
return YxNetInspectorConfig( return YxNetInspectorConfig(
showFloatingBall: showFloatingBall ?? this.showFloatingBall, showFloatingBall: showFloatingBall ?? this.showFloatingBall,
@ -80,6 +85,7 @@ class YxNetInspectorConfig {
draggable: draggable ?? this.draggable, draggable: draggable ?? this.draggable,
showBadge: showBadge ?? this.showBadge, showBadge: showBadge ?? this.showBadge,
autoHide: autoHide ?? this.autoHide, autoHide: autoHide ?? this.autoHide,
password: password ?? this.password,
); );
} }
@ -95,7 +101,8 @@ class YxNetInspectorConfig {
'initialPosition: $initialPosition, ' 'initialPosition: $initialPosition, '
'draggable: $draggable, ' 'draggable: $draggable, '
'showBadge: $showBadge, ' 'showBadge: $showBadge, '
'autoHide: $autoHide' 'autoHide: $autoHide, '
'password: ${password != null ? "***" : "null"}'
')'; ')';
} }
@ -112,7 +119,8 @@ class YxNetInspectorConfig {
other.initialPosition == initialPosition && other.initialPosition == initialPosition &&
other.draggable == draggable && other.draggable == draggable &&
other.showBadge == showBadge && other.showBadge == showBadge &&
other.autoHide == autoHide; other.autoHide == autoHide &&
other.password == password;
} }
@override @override
@ -128,6 +136,7 @@ class YxNetInspectorConfig {
draggable, draggable,
showBadge, showBadge,
autoHide, autoHide,
password,
); );
} }
} }

View File

@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// ///
@ -22,6 +24,7 @@ class NetworkLogEntry {
required this.timestamp, required this.timestamp,
required this.isSuccess, required this.isSuccess,
this.headers, this.headers,
this.responseHeaders,
this.requestData, this.requestData,
this.queryParameters, this.queryParameters,
this.statusCode, this.statusCode,
@ -34,6 +37,7 @@ class NetworkLogEntry {
final String method; final String method;
final String url; final String url;
final Map<String, dynamic>? headers; final Map<String, dynamic>? headers;
final Map<String, dynamic>? responseHeaders;
final dynamic requestData; final dynamic requestData;
final Map<String, dynamic>? queryParameters; final Map<String, dynamic>? queryParameters;
final int? statusCode; final int? statusCode;
@ -125,12 +129,39 @@ class NetworkLogEntry {
} }
} }
/// 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({ NetworkLogEntry copyWith({
String? id, String? id,
String? method, String? method,
String? url, String? url,
Map<String, dynamic>? headers, Map<String, dynamic>? headers,
Map<String, dynamic>? responseHeaders,
dynamic requestData, dynamic requestData,
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
int? statusCode, int? statusCode,
@ -146,6 +177,7 @@ class NetworkLogEntry {
method: method ?? this.method, method: method ?? this.method,
url: url ?? this.url, url: url ?? this.url,
headers: headers ?? this.headers, headers: headers ?? this.headers,
responseHeaders: responseHeaders ?? this.responseHeaders,
requestData: requestData ?? this.requestData, requestData: requestData ?? this.requestData,
queryParameters: queryParameters ?? this.queryParameters, queryParameters: queryParameters ?? this.queryParameters,
statusCode: statusCode ?? this.statusCode, statusCode: statusCode ?? this.statusCode,

View File

@ -9,6 +9,7 @@ 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_log_list.dart';
import 'package:yx_net_inspector/src/widgets/panel/inspector_search_bar.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/inspector_statistics.dart';
import 'package:yx_net_inspector/src/widgets/panel/password_dialog.dart';
/// ///
class YxInspectorPanel extends StatefulWidget { class YxInspectorPanel extends StatefulWidget {
@ -142,6 +143,16 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo
return ListenableBuilder( return ListenableBuilder(
listenable: widget.controller, listenable: widget.controller,
builder: (context, child) { builder: (context, child) {
//
if (!widget.controller.isUnlocked) {
return Center(
child: PasswordDialog(
theme: widget.theme,
onUnlock: widget.controller.unlock,
),
);
}
Widget content; Widget content;
if (_showDetailPage && _selectedLog != null) { if (_showDetailPage && _selectedLog != null) {
@ -174,6 +185,17 @@ ${log.duration != null ? '- 结束时间: ${_formatDateTime(log.timestamp.add(lo
isFullScreen: _isFullScreen, isFullScreen: _isFullScreen,
onToggleFullScreen: _toggleFullScreen, onToggleFullScreen: _toggleFullScreen,
onClearLogs: widget.controller.clearLogs, 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, onClose: widget.onClose,
), ),
InspectorStatistics( InspectorStatistics(

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:yx_net_inspector/src/models/inspector_theme.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/models/network_log_entry.dart';
@ -21,10 +22,21 @@ class YxLogDetailPage extends StatelessWidget {
children: [ children: [
_buildBasicInfoCard(context), _buildBasicInfoCard(context),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildCurlCard(context),
const SizedBox(height: 16),
_buildRequestInfoCard(), _buildRequestInfoCard(),
const SizedBox(height: 16), const SizedBox(height: 16),
if (log.headers != null && log.headers!.isNotEmpty) ...[
_buildHeadersCard('请求头 (Request Headers)', log.headers!),
const SizedBox(height: 16),
],
_buildResponseInfoCard(), _buildResponseInfoCard(),
const SizedBox(height: 16), const SizedBox(height: 16),
if (log.responseHeaders != null &&
log.responseHeaders!.isNotEmpty) ...[
_buildHeadersCard('响应头 (Response Headers)', log.responseHeaders!),
const SizedBox(height: 16),
],
if (log.errorMessage != null) ...[ if (log.errorMessage != null) ...[
_buildErrorInfoCard(), _buildErrorInfoCard(),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -387,4 +399,123 @@ class YxLogDetailPage extends StatelessWidget {
'${dateTime.minute.toString().padLeft(2, '0')}:' '${dateTime.minute.toString().padLeft(2, '0')}:'
'${dateTime.second.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

@ -50,7 +50,6 @@ class InspectorDetailHeader extends StatelessWidget {
return isFullScreen return isFullScreen
? AppBar( ? AppBar(
title: const Text('请求详情'),
backgroundColor: theme.primaryColor, backgroundColor: theme.primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
// excludeHeaderSemantics: false // excludeHeaderSemantics: false

View File

@ -8,6 +8,7 @@ class InspectorHeader extends StatelessWidget {
required this.onToggleFullScreen, required this.onToggleFullScreen,
required this.onClearLogs, required this.onClearLogs,
required this.onClose, required this.onClose,
this.onCopyLogs,
super.key, super.key,
}); });
@ -16,6 +17,7 @@ class InspectorHeader extends StatelessWidget {
final VoidCallback onToggleFullScreen; final VoidCallback onToggleFullScreen;
final VoidCallback onClearLogs; final VoidCallback onClearLogs;
final VoidCallback onClose; final VoidCallback onClose;
final VoidCallback? onCopyLogs;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -33,6 +35,12 @@ class InspectorHeader extends StatelessWidget {
icon: const Icon(Icons.clear_all, color: Colors.white), icon: const Icon(Icons.clear_all, color: Colors.white),
tooltip: '清空日志', tooltip: '清空日志',
), ),
if (onCopyLogs != null)
IconButton(
onPressed: onCopyLogs,
icon: const Icon(Icons.copy, color: Colors.white),
tooltip: '复制所有日志',
),
IconButton( IconButton(
onPressed: onClose, onPressed: onClose,
icon: const Icon(Icons.close, color: Colors.white), icon: const Icon(Icons.close, color: Colors.white),
@ -60,17 +68,6 @@ class InspectorHeader extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ 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, ...actions,
], ],
), ),

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

@ -7,9 +7,9 @@ import 'package:yx_net_inspector/src/widgets/inspector_panel.dart';
/// ///
class YxNetInspector extends StatefulWidget { class YxNetInspector extends StatefulWidget {
const YxNetInspector({ const YxNetInspector({
required this.child, super.key, required this.child,
super.key,
this.config = const YxNetInspectorConfig(), this.config = const YxNetInspectorConfig(),
this.theme = const YxNetInspectorTheme(), this.theme = const YxNetInspectorTheme(),
}); });
@ -42,6 +42,7 @@ class YxNetInspector extends StatefulWidget {
child: child, child: child,
); );
} }
/// ///
final Widget child; final Widget child;
@ -170,12 +171,14 @@ class YxNetInspectorGlobal {
int? statusCode, int? statusCode,
dynamic responseData, dynamic responseData,
Duration? duration, Duration? duration,
Map<String, dynamic>? responseHeaders,
}) { }) {
controller.logResponse( controller.logResponse(
id: id, id: id,
statusCode: statusCode, statusCode: statusCode,
responseData: responseData, responseData: responseData,
duration: duration, duration: duration,
responseHeaders: responseHeaders,
); );
} }

View File

@ -1,6 +1,6 @@
// //
export 'src/controller/yx_net_inspector_controller.dart'; export 'src/controller/yx_net_inspector_controller.dart';
export 'src/interceptors/dio_interceptor.dart';
export 'src/models/inspector_config.dart'; export 'src/models/inspector_config.dart';
export 'src/models/inspector_theme.dart'; export 'src/models/inspector_theme.dart';
export 'src/models/network_log_entry.dart'; export 'src/models/network_log_entry.dart';

View File

@ -41,6 +41,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.19.1" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -59,6 +75,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -107,6 +131,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.16.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -168,6 +200,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "0.7.6" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -192,6 +232,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "15.0.0" 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: sdks:
dart: ">=3.8.0-0 <4.0.0" dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View File

@ -1,6 +1,6 @@
name: yx_net_inspector 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. 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.4 version: 1.1.0
homepage: https://github.com/your-username/yx_net_inspector homepage: https://github.com/your-username/yx_net_inspector
environment: environment:
@ -10,6 +10,7 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
dio: ^5.9.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

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

@ -4,6 +4,7 @@ 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_config.dart';
import 'package:yx_net_inspector/src/models/inspector_theme.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/inspector_panel.dart';
import 'package:yx_net_inspector/src/widgets/panel/inspector_log_list.dart';
void main() { void main() {
group('YxInspectorPanel Widget Tests', () { group('YxInspectorPanel Widget Tests', () {
@ -34,20 +35,25 @@ void main() {
), ),
); );
// // ()
expect(find.text('网络检查器'), findsOneWidget); // Title removed from non-fullscreen mode to save space
expect(find.byIcon(Icons.network_check), findsOneWidget);
expect(find.byIcon(Icons.close), findsOneWidget); expect(find.byIcon(Icons.close), findsOneWidget);
}); });
testWidgets('应该显示统计信息', (WidgetTester tester) async { testWidgets('应该显示统计信息', (WidgetTester tester) async {
// //
controller.logRequest( 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.logResponse(id: 'test-1', statusCode: 200);
controller.logRequest( 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: '网络错误'); controller.logError(id: 'test-2', error: '网络错误');
await tester.pumpWidget( await tester.pumpWidget(
@ -65,12 +71,15 @@ void main() {
await tester.pump(); await tester.pump();
// //
expect(find.text('总计'), findsOneWidget); //
expect(find.text('成功'), findsOneWidget); expect(find.text('总请求: 2'), findsOneWidget);
expect(find.text('失败'), findsOneWidget); expect(find.text('错误: 1'), findsOneWidget);
expect(find.text('成功率'), findsOneWidget);
expect(find.text('2'), findsOneWidget); // // Removed old fields
expect(find.text('1'), findsWidgets); // 1 // expect(find.text('总计'), findsOneWidget);
// expect(find.text('成功'), findsOneWidget);
// expect(find.text('失败'), findsOneWidget);
// expect(find.text('成功率'), findsOneWidget);
}); });
testWidgets('应该显示搜索栏', (WidgetTester tester) async { testWidgets('应该显示搜索栏', (WidgetTester tester) async {
@ -89,16 +98,24 @@ void main() {
// //
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);
expect(find.text('搜索请求...'), 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 { testWidgets('搜索功能应该正常工作', (WidgetTester tester) async {
// //
controller.logRequest( 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( 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( await tester.pumpWidget(
MaterialApp( MaterialApp(
@ -125,11 +142,17 @@ void main() {
testWidgets('错误过滤器应该正常工作', (WidgetTester tester) async { testWidgets('错误过滤器应该正常工作', (WidgetTester tester) async {
// //
controller.logRequest( 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.logResponse(id: 'success', statusCode: 200);
controller.logRequest( controller.logRequest(
id: 'error', method: 'POST', url: 'https://example.com',); id: 'error',
method: 'POST',
url: 'https://example.com',
);
controller.logError(id: 'error', error: '网络错误'); controller.logError(id: 'error', error: '网络错误');
await tester.pumpWidget( await tester.pumpWidget(
@ -146,19 +169,21 @@ void main() {
await tester.pump(); await tester.pump();
// // (It's a TextButton now)
await tester.tap(find.byType(FilterChip)); await tester.tap(find.text('仅错误'));
await tester.pump(); await tester.pump();
// // - Text changes to "显示全部"
final filterChip = tester.widget<FilterChip>(find.byType(FilterChip)); expect(find.text('显示全部'), findsOneWidget);
expect(filterChip.selected, isTrue);
}); });
testWidgets('清空日志按钮应该正常工作', (WidgetTester tester) async { testWidgets('清空日志按钮应该正常工作', (WidgetTester tester) async {
// //
controller.logRequest( controller.logRequest(
id: 'test', method: 'GET', url: 'https://example.com',); id: 'test',
method: 'GET',
url: 'https://example.com',
);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
@ -272,7 +297,7 @@ void main() {
await tester.pump(); await tester.pump();
// //
expect(find.byType(Card), findsWidgets); expect(find.byType(InspectorLogItem), findsWidgets);
expect(find.text('GET'), findsOneWidget); expect(find.text('GET'), findsOneWidget);
}); });
@ -300,7 +325,7 @@ void main() {
await tester.pump(); await tester.pump();
// //
await tester.tap(find.byType(InkWell).first); await tester.tap(find.byType(InspectorLogItem).first);
await tester.pumpAndSettle(); 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);
});
});
}