diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
new file mode 100644
index 0000000..fc04e07
--- /dev/null
+++ b/.github/workflows/verify.yml
@@ -0,0 +1,25 @@
+name: Verify
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ flutter:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.44.7'
+ channel: stable
+ cache: true
+ - run: flutter pub get
+ - run: dart format --output=none --set-exit-if-changed lib test example/lib example/test
+ - run: flutter analyze --no-pub
+ - run: flutter test --no-pub --coverage
+ - run: flutter test --no-pub
+ working-directory: example
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0713545..34eb2e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,22 @@
+## Unreleased
+
+* Generate list snapshots lazily, retain cached sanitized details on ordinary response updates and add a reproducible ingestion benchmark.
+* Add pause/resume without deleting retained logs, method/host/slow/cancelled filters, general-log level/search filters and programmatic query APIs.
+* Add sanitized HAR/JSON exports, POSIX cURL templates, background whole-list encoding on native platforms and clipboard feedback.
+* Preserve console filter selections across details and tabs, and omit credentials embedded in URLs or redirect locations.
+* Bound network/general logs, UTF-8 body/message previews and structured body traversal; coalesce subscriber notifications during bursts.
+* Use unique request IDs and constant-time log lookup; remove the unused Dio request map.
+* Preserve http stream errors, forward pause/resume/cancellation and capture headers before body consumption without altering caller payloads.
+* Make debugPrint initialization idempotent, add shutdown and implement EasyDebug.log.
+* Disable capture/UI by default in release builds; add configurable sensitive-header and structured-credential masking.
+* Refresh the console with adaptive light/dark cards, search, pending filters, bounded dragging and live details inside the console.
+* Replace the stale example test, add network/manager/widget regressions and a verification workflow; update setup and release documentation.
+
+## 0.0.4
+
+* Add standard http client support and a separate general-log view.
+* Add debugPrint capture and update the debug console layout.
+
## 0.0.3
* **[DOCS] Update README**: Added comprehensive documentation for Environment Switcher feature.
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
new file mode 100644
index 0000000..d835787
--- /dev/null
+++ b/PERFORMANCE.md
@@ -0,0 +1,24 @@
+# Log ingestion performance
+
+The benchmark is `test/performance_test.dart`:
+
+```sh
+flutter test --no-pub test/performance_test.dart --reporter expanded
+```
+
+Each of three consecutive samples writes 100,000 short general-log messages, with a listener attached and retention set to 200. The measured interval contains ingestion only. The test then reads the snapshot, verifies its contents and independence from later changes, and checks one batched notification per burst. Timing is diagnostic; there is no machine-dependent pass/fail threshold.
+
+Observed on this macOS workspace using Flutter 3.44.7 / Dart 3.12.2 in the debug test process:
+
+| Implementation | Sample 1 | Sample 2 | Sample 3 | Median |
+| --- | ---: | ---: | ---: | ---: |
+| Baseline `031b962` | 151.554 ms | 115.314 ms | 109.887 ms | 115.314 ms |
+| Lazy snapshots in this change | 29.580 ms | 14.246 ms | 12.515 ms | 14.246 ms |
+
+The observed median ingestion time was about 8.1 times lower. Both versions retained 200 messages and emitted one notification per burst. These are local samples, not a frame-rate claim or a performance guarantee across devices. JIT, garbage collection, load and message size affect results.
+
+The previous implementation copied the entire retained list on every message. The new path appends/trims the bounded queue and invalidates its cached snapshot; the list is copied only when a consumer reads a changed revision. A listener reading on every write still pays for those reads. Network response updates also reuse already sanitized, unchanged request/response details; configuration changes and caller-mutated stored records force sanitization again.
+
+The console uses lazy slivers for result lists and debounces text search. Whole-list copy/export uses a compute isolate on native platforms; Web compute runs on its current event loop. Captured body and log-count limits continue to apply. Exports do not add persistent disk storage or background uploads.
+
+Real-device profile-mode frame timings, heap allocation traces and long-duration memory curves remain unmeasured. Use Flutter DevTools on the target app to assess those under its actual request rate and payload distribution.
diff --git a/PUBLISHING.md b/PUBLISHING.md
index 12ff107..8c0073f 100644
--- a/PUBLISHING.md
+++ b/PUBLISHING.md
@@ -1,25 +1,21 @@
-# Publishing to pub.dev
+# Publishing
-Follow these steps to publish `easy_debug` to pub.dev.
+Before publishing a new version:
-## 1. Final Review
-- **Repository URL**: Open `pubspec.yaml` and uncomment/fill in the `repository` line with your GitHub URL. This is critical for pub.dev verification score.
-- **License**: Ensure `LICENSE` file has your correct name (already done).
+1. Update the version in pubspec.yaml and the HAR creator version in lib/src/core/log_export.dart, and move the unreleased Changelog entries to that version.
+2. Verify the repository metadata and supported SDK/platform claims.
+3. Run the formatting, analysis and test commands from README.md.
+4. Inspect the example on supported devices, including a profile build for frame/memory checks and a release build to confirm the default disabled behavior.
+5. Run the packaging check:
-## 2. Dry Run
-Run this command to check for errors:
-```bash
+```sh
flutter pub publish --dry-run
```
-Fix any warnings that appear.
-## 3. Publish
-```bash
+Only publish after reviewing the archive and warnings:
+
+```sh
flutter pub publish
```
-1. Type `y` to confirm.
-2. Click the link to zero-auth with your Google Account.
-3. Success!
-## 4. Verification
-Visit [pub.dev](https://pub.dev/packages/easy_debug) to see your package.
+Publishing is a separate release action. The CI workflow and a dry run do not publish the package.
diff --git a/README.md b/README.md
index ab424b1..ba61684 100644
--- a/README.md
+++ b/README.md
@@ -1,187 +1,210 @@
# Easy Debug
-A pure Dart, lightweight, and powerful in-app network debugger for Flutter.
-`easy_debug` provides a floating overlay to monitor your Dio network requests in real-time, inspect details, and manage logs without connecting to an external debugger.
+An in-app Flutter console for inspecting **Dio**, **http** and application logs.
+Open the floating button to see requests, inspect responses, copy details and switch API environments.
-## Screenshots
+
+
+
+
+
-
-
-
-
-
+
+
+
+
## Features
-* 🚀 **Pure Dart**: No native dependencies, works on all Flutter platforms (Android, iOS, Web, Desktop).
-* 🌐 **Universal Network Support**: Supports both **Dio** and standard **http** package.
-* 📝 **General Logs**: Capture `debugPrint` and `EasyDebug.log` messages in a dedicated terminal-style tab.
-* 📱 **Floating Overlay**: Always accessible Draggable floating button.
-* 💎 **Glassmorphism UI**: Modern, semi-transparent design.
-* 🌍 **Environment Switcher**: Switch API environments (Dev/Prod) at runtime with persistence.
-* 🔍 **Detailed Inspection**: View headers, body, timestamp, and duration for Requests and Responses.
-* 📂 **Categorization**: Filter network logs by "All", "Success", or "Error" tabs.
-* 📋 **Smart Copy**: One-tap copy for Request/Response/Log content.
-* 🧹 **Log Management**: Auto-clearing (optional) and manual clear support.
+- Dio interceptor and an http client wrapper; existing caller responses and errors pass through.
+- Request cards, URL/method search, success/error/pending filters and live details.
+- Method/host/slow-request filters, a separate cancelled state and query-preserving navigation.
+- Filtered HAR export, JSON log export, cURL templates and pause/resume capture.
+- Light and dark themes, adaptive panel width, draggable button with screen bounds, keyboard-aware layout.
+- Request/response headers and body previews, duration, errors and clipboard copy.
+- Bounded network/general logs and body previews; burst notifications are coalesced.
+- Environment selection persisted with shared_preferences.
+- Capture and UI disabled by default in release builds.
+
+The package implementation is Dart/Flutter. Environment persistence uses the shared_preferences platform plugin. This repository includes Android/iOS example projects; platform support also depends on those dependencies and your host application.
## Installation
-Add `easy_debug` to your `pubspec.yaml`:
+Use the Git checkout while evaluating these changes:
```yaml
dependencies:
easy_debug:
- path: ./ # Or git/pub version
+ git:
+ url: https://github.com/bobo928/flutter_easy_debug.git
+ ref: main
```
-## Setup
-
-### 1. Initialize & Wrap MaterialApp
+Dart 3.10.1 or newer (below 4.0) is required, together with a compatible Flutter SDK.
+The verification workflow currently uses Flutter 3.44.7 / Dart 3.12.2.
-Wrap your app with `EasyDebugWidget` using the `builder` property of `MaterialApp` (or `CupertinoApp`).
-Also, add the `EasyDebugNavigatorObserver` to handle navigation correctly (e.g., closing overlay on page changes or handling context).
+## Initialize and display the console
```dart
import 'package:easy_debug/easy_debug.dart';
+import 'package:flutter/material.dart';
-void main() {
+Future main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await EasyDebugManager().init(); // Enables debugPrint capture; idempotent.
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
-
@override
- Widget build(BuildContext context) {
- return MaterialApp(
- title: 'Easy Debug Example',
- // 1. Wrap your app in the builder
- builder: (context, child) {
- return EasyDebugWidget(child: child!);
- },
- // 2. Add the navigator observer
- navigatorObservers: [
- EasyDebugNavigatorObserver(),
- ],
- home: const MyHomePage(),
- );
- }
+ Widget build(BuildContext context) => MaterialApp(
+ builder: (context, child) => EasyDebugWidget(child: child!),
+ navigatorObservers: [EasyDebugNavigatorObserver()],
+ home: const Scaffold(body: Center(child: Text('My app'))),
+ );
}
```
-### 2. Add Dio Interceptor
+Tap the bug button to open the console. Details stay inside the console and update when a request finishes, without pushing a host application route. Escape or the close button dismisses the console. The observer is needed only if you want automatic clearing when host routes are pushed.
-Attach the `EasyDebugDioInterceptor` to your Dio instance to start capturing network events.
+## Connect the clients you actually use
```dart
-final dio = Dio();
+final dio = Dio()..interceptors.add(EasyDebugDioInterceptor());
-// Add the interceptor
-dio.interceptors.add(EasyDebugDioInterceptor());
+final client = EasyDebugHttpClient(http.Client());
+final response = await client.get(Uri.parse('https://example.com/data'));
+// Close the wrapper when the owning component is disposed.
+client.close();
```
-### 3. Usage with clean `http` package (If necessary)
+Import http as usual: `import 'package:http/http.dart' as http;`.
-To debug requests made with the `http` package, simply wrap your client with `EasyDebugHttpClient`.
+Requests from other client instances are not captured. Multipart/streamed request bodies are represented by a placeholder. Binary bodies are not rendered as JSON.
-```dart
-import 'package:http/http.dart' as http;
-import 'package:easy_debug/easy_debug.dart';
+The http wrapper retains at most a bounded response prefix, forwards chunks and pause/resume to the source, and records cancellation. Headers are available before body consumption; a response is pending until its stream completes. Duration includes time spent consuming that stream. A stream that is never listened to remains pending.
-final client = EasyDebugHttpClient(http.Client());
+## Application logs
-// Use as normal
-final response = await client.get(Uri.parse('https://example.com'));
+```dart
+debugPrint('A message captured after init()');
+EasyDebug.log('Cache refreshed', level: 'INFO');
+EasyDebugManager().addGeneralLog('Explicit log entry');
```
-## Environment Switching
+Ordinary `print()` and other isolates are not intercepted. Levels are optional. Tap a log card to copy the complete retained message.
-Easy Debug allows you to switch API environments (e.g., Dev, Test, Prod) at runtime.
+Call `EasyDebugManager().shutdown()` to stop recording, clear retained logs and restore the debugPrint hook installed by this package. It does not overwrite a replacement hook installed later by another component. Re-enable configuration and call `init()` to start a new session.
-### 1. Initialize Environments
+## Filter, pause and export
-Initialize the manager with your environment list in `main()` before `runApp`.
+Use the tune button in Network to combine method, host and slow-request filters. Slow requests are those with a recorded duration of at least one second; pending requests are not treated as slow. Status filters distinguish success, error, pending and cancelled requests. Filters survive opening a detail page and switching console tabs.
+
+The pause button stops **new** network and general-log captures while keeping existing records visible. Responses for requests already recorded continue to update. Resume starts capturing new requests again; pause does not change your app's network behavior.
```dart
-void main() async {
- WidgetsFlutterBinding.ensureInitialized();
-
- await EasyDebugManager().init(environments: [
- const AppEnvironment(
- name: 'Production',
- baseUrl: 'https://api.example.com',
- isDefault: true,
- ),
- const AppEnvironment(
- name: 'Development',
- baseUrl: 'https://dev.api.example.com',
- baseUrl: 'https://dev.api.example.com',
- ),
- ]);
-
- runApp(const MyApp());
-}
+final manager = EasyDebugManager();
+manager.pauseRecording();
+manager.resumeRecording();
+
+const query = NetworkLogQuery(
+ method: 'POST',
+ host: 'api.example.com',
+ status: NetworkLogStatus.error,
+ minDurationMs: 1000,
+);
+final requests = manager.queryNetworkLogs(query);
+final har = manager.exportHar(query: query);
+final json = manager.exportNetworkJson(query: query);
+final curl = requests.isEmpty ? '' : EasyDebugExport.curl(requests.first);
+final messages = manager.exportGeneralJson(
+ query: const GeneralLogQuery(search: 'retry', severity: GeneralLogSeverity.warning),
+);
```
-### 2. Use Dynamic Base URL
+The Network download icon copies only the filtered requests as HAR. The Logs download icon copies filtered messages as JSON, and details provide a cURL copy button. These actions copy text; they never replay a request, upload logs or write files automatically.
-Update your Dio client to listen for environment changes.
+Whole-list export buttons use asynchronous encoders so native platforms can encode in a compute isolate. Programmatic callers can use `EasyDebugExport.harAsync(records, config: manager.config)` or `generalJsonAsync(records)`. Flutter Web compute runs on the current event loop; export remains bounded by configured retention/body limits.
-```dart
-class _MyAppState extends State {
- final Dio _dio = Dio();
+HAR output follows the [HAR 1.2 structure](https://github.com/ahmadnassri/har-spec/blob/master/versions/1.2.md). It contains **captured previews**, not original wire payloads. Unknown wire/header sizes are `-1`; content size is the exported text size. Only total elapsed time is available and is placed in the wait field with an explanatory comment; DNS/TLS/send/receive phases and HTTP version are not measured. Extra metadata records pending, cancellation and truncation. No browser-import compatibility claim is made for every HAR consumer.
- @override
- void initState() {
- super.initState();
- // Set initial URL
- _dio.options.baseUrl = EasyDebugManager().currentBaseUrl;
-
- // Listen for changes
- EasyDebugManager().currentEnvNotifier.addListener(() {
- final newEnv = EasyDebugManager().currentEnvNotifier.value;
- if (newEnv != null) {
- _dio.options.baseUrl = newEnv.baseUrl;
- }
- });
- }
-}
+cURL output is a **POSIX-shell template**. Credentials remain masked, truncated/unavailable bodies are omitted, and stale framing headers are left for cURL to compute. Restore credentials and the full original body before using it for reproduction. JSON/HAR/cURL exports reapply the current capture policy. Plain log messages and error text still require application-specific scrubbing.
+
+## Resource limits and capture policy
+
+```dart
+EasyDebugManager().updateConfig(const EasyDebugConfig(
+ // Defaults to false in release builds; omit to keep that default.
+ enabled: true,
+ maxLogCount: 50,
+ maxGeneralLogCount: 200,
+ maxBodyBytes: 65536,
+ maxMessageLength: 4000,
+ clearOnNavigation: false,
+));
```
-## Usage
+Zero record capacity disables retention of that log type. Limits must be non-negative and message length must be positive. Reducing a limit trims retained data. Disabling capture clears records, hides the overlay and bypasses instrumentation for new requests.
-* **Open Console**: Tap the 🐞 floating button.
-* **Switch Views**: Use the header tabs to switch between **Network**, **Logs**, and **Settings**.
-* **Network Tab**: View Dio/Http requests. Filter by "All", "Success", or "Error".
-* **Logs Tab**: View `debugPrint` output with timestamp and coloring. Tap any row to copy.
-* **Settings Tab**: Switch environments.
-* **View Details**: Tap any network log item to see full Request/Response details.
-* **Smart Copy**: Tap the copy icon in detailed view, or tap any row in the Logs tab to copy content.
-* **Clear Logs**: Tap the Trash icon in the console header.
+- Each request/response body preview is bounded in UTF-8 bytes. Truncated previews are labeled in details and copied output. Large structured bodies are traversed only up to the preview budget, with a nesting-depth limit.
+- Incomplete or oversized JSON text is omitted when it cannot be safely parsed within the budget; caller data remains intact.
+- General message length is also a UTF-8 byte budget. Headers are limited to 100 entries, with bounded names and values.
+- Sensitive headers (Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-API-Key) are masked by default. Common credential keys are masked in structured JSON and URL queries. Customize `sensitiveHeaders` and `sensitiveKeys` if your API uses other names.
+- Plain-text bodies, error text and application log messages are not automatically scrubbed. Avoid logging secrets in those channels.
+- Reading a notifier returns current values immediately. Immutable list snapshots are generated lazily and cached until the next change; subscriber notifications are batched once per event-loop turn.
+- Logs are held in memory only; environment selection is the only persisted data.
-## Configuration
+Keep debug capture disabled for normal release distribution. If you explicitly enable it, apply the policies appropriate to that build.
-You can configure global settings via `EasyDebugManager`.
+## Environment switching
```dart
-EasyDebugManager().updateConfig(
- EasyDebugConfig(
- maxLogCount: 100, // Maximum logs to keep in memory
- clearOnNavigation: false, // Auto clear logs when navigating pages
+await EasyDebugManager().init(environments: const [
+ AppEnvironment(
+ name: 'Production',
+ baseUrl: 'https://api.example.com',
+ isDefault: true,
),
-);
+ AppEnvironment(
+ name: 'Development',
+ baseUrl: 'https://dev.example.com',
+ ),
+]);
```
+The manager restores a saved environment name, then falls back to the default or first entry. Names should be unique. The host must update its clients:
-## Author
+```dart
+void onEnvironmentChanged() {
+ dio.options.baseUrl = EasyDebugManager().currentBaseUrl;
+}
-Created by **JasonBoolean**.
+onEnvironmentChanged();
+EasyDebugManager().currentEnvNotifier.addListener(onEnvironmentChanged);
+// Remove the listener when its owner is disposed.
+EasyDebugManager().currentEnvNotifier.removeListener(onEnvironmentChanged);
+```
-## License
+For http, construct new request URIs from the current base URL. Switching environments does not rewrite requests already in flight.
+
+## Development
+
+```sh
+flutter pub get
+dart format --output=none --set-exit-if-changed lib test example/lib example/test
+flutter analyze --no-pub
+flutter test --no-pub
+cd example
+flutter test --no-pub
+```
-This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+Tests use local adapters and streams, not public API endpoints. They cover retention, redaction, repeated initialization, concurrent HTTP requests, stream errors/cancellation/backpressure, and console interaction at phone and desktop sizes.
+
+See [PERFORMANCE.md](PERFORMANCE.md) for the reproducible ingestion benchmark and its limits.
+
+See [CHANGELOG.md](CHANGELOG.md) for changes and [PUBLISHING.md](PUBLISHING.md) for release checks.
+
+## License
+MIT. Original project by JasonBoolean.
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 3876551..553d093 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -163,9 +163,7 @@ class _HomePageState extends State {
ElevatedButton(
onPressed: () {
debugPrint('This is a test log at ${DateTime.now()}');
- print(
- 'This print() might be captured if we used runZoned, but for now debugPrint is safer.',
- );
+ EasyDebug.log('An explicit application log', level: 'INFO');
debugPrint('Warning: This is a simulated warning message.');
debugPrint('Error: This is a simulated error message!');
},
diff --git a/example/pubspec.lock b/example/pubspec.lock
index 9949fd0..38dabef 100644
--- a/example/pubspec.lock
+++ b/example/pubspec.lock
@@ -130,7 +130,7 @@ packages:
source: sdk
version: "0.0.0"
http:
- dependency: transitive
+ dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 2c6e91f..a729358 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -29,6 +29,7 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dio: ^5.9.0
+ http: ^1.6.0
dev_dependencies:
integration_test:
diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart
index 07174bd..ec4c54c 100644
--- a/example/test/widget_test.dart
+++ b/example/test/widget_test.dart
@@ -1,27 +1,28 @@
-// This is a basic Flutter widget test.
-//
-// To perform an interaction with a widget in your test, use the WidgetTester
-// utility in the flutter_test package. For example, you can send tap and scroll
-// gestures. You can also use WidgetTester to find child widgets in the widget
-// tree, read text, and verify that the values of widget properties are correct.
-
+import 'package:easy_debug/easy_debug.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
-
import 'package:easy_debug_example/main.dart';
void main() {
- testWidgets('Verify Platform version', (WidgetTester tester) async {
- // Build our app and trigger a frame.
+ testWidgets('demo exposes the console and switches its views', (
+ tester,
+ ) async {
+ final manager = EasyDebugManager();
+ manager.updateConfig(const EasyDebugConfig());
await tester.pumpWidget(const MyApp());
-
- // Verify that platform version is retrieved.
- expect(
- find.byWidgetPredicate(
- (Widget widget) => widget is Text &&
- widget.data!.startsWith('Running on:'),
- ),
- findsOneWidget,
- );
+ expect(find.text('Easy Debug Demo'), findsOneWidget);
+ expect(find.text('GET Success (200)'), findsOneWidget);
+ await tester.tap(find.byTooltip('Open Easy Debug'));
+ await tester.pumpAndSettle();
+ expect(find.text('Waiting for requests'), findsOneWidget);
+ await tester.tap(find.text('Logs'));
+ await tester.pumpAndSettle();
+ expect(find.text('A little more context'), findsOneWidget);
+ await tester.tap(find.byTooltip('Close console'));
+ await tester.pumpAndSettle();
+ expect(find.byTooltip('Open Easy Debug'), findsOneWidget);
+ await tester.pumpWidget(const SizedBox.shrink());
+ manager.shutdown();
+ await tester.pump();
});
}
diff --git a/lib/easy_debug.dart b/lib/easy_debug.dart
index 5a73d49..bbef856 100644
--- a/lib/easy_debug.dart
+++ b/lib/easy_debug.dart
@@ -1,11 +1,14 @@
-export 'src/core/log_manager.dart' show EasyDebugManager;
+export 'src/core/log_manager.dart' show EasyDebugManager, EasyDebug;
export 'src/core/config_model.dart' show AppEnvironment;
+export 'src/core/log_query.dart';
+export 'src/core/log_export.dart' show EasyDebugExport;
export 'src/core/log_model.dart'
show
NetworkLog,
RequestDetails,
ResponseDetails,
ErrorDetails,
+ GeneralLog,
EasyDebugConfig;
export 'src/core/easy_debug_navigator_observer.dart';
export 'src/network/dio_interceptor.dart';
diff --git a/lib/src/core/capture.dart b/lib/src/core/capture.dart
new file mode 100644
index 0000000..b08eea7
--- /dev/null
+++ b/lib/src/core/capture.dart
@@ -0,0 +1,255 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
+import 'log_model.dart';
+
+/// Clips at a UTF-8 boundary without encoding an arbitrarily large string.
+String boundedText(String text, int maxBytes) {
+ if (maxBytes <= 0) return '';
+ final prefix = text.length > maxBytes ? text.substring(0, maxBytes) : text;
+ final bytes = utf8.encode(prefix);
+ if (bytes.length <= maxBytes) return prefix;
+ var end = maxBytes;
+ while (end > 0 && (bytes[end] & 0xc0) == 0x80) {
+ end--;
+ }
+ return utf8.decode(bytes.sublist(0, end));
+}
+
+Map captureHeaders(
+ Map headers,
+ EasyDebugConfig config,
+) {
+ final hidden = config.sensitiveHeaders.map((e) => e.toLowerCase()).toSet();
+ return Map.unmodifiable(
+ Map.fromEntries(
+ headers.entries.take(100).map((entry) {
+ final text = _headerText(entry.value, config.maxMessageLength);
+ return MapEntry(
+ boundedText(entry.key, 256),
+ hidden.contains(entry.key.toLowerCase())
+ ? '[REDACTED]'
+ : entry.key.toLowerCase() == 'location'
+ ? captureUrl(text, config)
+ : boundedText(text, config.maxMessageLength),
+ );
+ }),
+ ),
+ );
+}
+
+String _headerText(dynamic value, int limit) {
+ if (value is! Iterable) return boundedText(value.toString(), limit);
+ final buffer = StringBuffer();
+ var bytes = 0;
+ for (final item in value.take(20)) {
+ if (bytes >= limit) break;
+ if (buffer.isNotEmpty) {
+ final separator = boundedText(', ', limit - bytes);
+ buffer.write(separator);
+ bytes += separator.length;
+ }
+ final text = boundedText(item.toString(), limit - bytes);
+ bytes += utf8.encode(text).length;
+ buffer.write(text);
+ }
+ return buffer.toString();
+}
+
+String captureUrl(String url, EasyDebugConfig config) {
+ final uri = Uri.tryParse(url);
+ if (uri == null) return boundedText(url, 8192);
+ final hidden = config.sensitiveKeys.map((e) => e.toLowerCase()).toSet();
+ return boundedText(
+ uri
+ .removeFragment()
+ .replace(
+ userInfo: '',
+ queryParameters: !uri.hasQuery
+ ? null
+ : uri.queryParametersAll.map(
+ (key, value) => MapEntry(
+ key,
+ hidden.contains(key.toLowerCase()) ? ['[REDACTED]'] : value,
+ ),
+ ),
+ )
+ .toString(),
+ 8192,
+ );
+}
+
+class CapturedBody {
+ const CapturedBody(this.text, this.truncated);
+ final String? text;
+ final bool truncated;
+}
+
+/// Visits only a bounded prefix rather than JSON-encoding a complete payload.
+CapturedBody captureBody(
+ dynamic body,
+ EasyDebugConfig config, {
+ bool sourceTruncated = false,
+}) {
+ if (body == null) return const CapturedBody(null, false);
+ if (body is Uint8List || body is Stream) {
+ return const CapturedBody('', false);
+ }
+ if (body is String &&
+ (sourceTruncated || body.length > config.maxBodyBytes)) {
+ final start = body.substring(0, body.length.clamp(0, 64)).trimLeft();
+ if (start.startsWith('{') || start.startsWith('[')) {
+ return const CapturedBody('', true);
+ }
+ }
+ if (body is String && body.length <= config.maxBodyBytes) {
+ try {
+ body = jsonDecode(body);
+ } catch (_) {
+ /* Plain text stays plain. */
+ }
+ }
+ final writer = _BodyWriter(config);
+ writer.write(body, 0, plainText: body is String);
+ return CapturedBody(writer.buffer.toString(), writer.truncated);
+}
+
+class _BodyWriter {
+ _BodyWriter(this.config);
+ final EasyDebugConfig config;
+ final buffer = StringBuffer();
+ int bytes = 0;
+ bool truncated = false;
+
+ void append(String text) {
+ final clipped = boundedText(text, config.maxBodyBytes - bytes);
+ buffer.write(clipped);
+ bytes += utf8.encode(clipped).length;
+ if (clipped.length < text.length) truncated = true;
+ }
+
+ void write(dynamic value, int depth, {bool plainText = false}) {
+ if (bytes >= config.maxBodyBytes) {
+ truncated = true;
+ return;
+ }
+ if (depth > 8) {
+ append('""');
+ truncated = true;
+ return;
+ }
+ if (value is Map) {
+ append('{');
+ var first = true;
+ for (final entry in value.entries) {
+ if (bytes >= config.maxBodyBytes) {
+ truncated = true;
+ break;
+ }
+ if (!first) append(', ');
+ first = false;
+ final key = boundedText(entry.key.toString(), config.maxMessageLength);
+ append(jsonEncode(key));
+ append(': ');
+ write(
+ config.sensitiveKeys.any((e) => e.toLowerCase() == key.toLowerCase())
+ ? '[REDACTED]'
+ : entry.value,
+ depth + 1,
+ );
+ }
+ append('}');
+ } else if (value is List) {
+ append('[');
+ for (var i = 0; i < value.length; i++) {
+ if (bytes >= config.maxBodyBytes) {
+ truncated = true;
+ break;
+ }
+ if (i > 0) append(', ');
+ write(value[i], depth + 1);
+ }
+ append(']');
+ } else {
+ if (value != null &&
+ value is! String &&
+ value is! num &&
+ value is! bool) {
+ append('""');
+ return;
+ }
+ final text = value?.toString() ?? 'null';
+ final prefix = boundedText(text, config.maxBodyBytes - bytes);
+ if (prefix.length < text.length) truncated = true;
+ append(value is String && !plainText ? jsonEncode(prefix) : prefix);
+ }
+ }
+}
+
+NetworkLog captureNetworkLog(
+ NetworkLog log,
+ EasyDebugConfig config, {
+ NetworkLog? previous,
+}) {
+ // Caller-mutated stored records must still go through full sanitization.
+ if (identical(log, previous)) previous = null;
+ final request = log.request;
+ final response = log.response;
+ final keepRequest = previous != null && identical(request, previous.request);
+ final keepResponse =
+ previous != null && identical(response, previous.response);
+ final requestBody = keepRequest
+ ? const CapturedBody(null, false)
+ : captureBody(
+ request?.body,
+ config,
+ sourceTruncated: request?.bodyTruncated ?? false,
+ );
+ final responseBody = keepResponse
+ ? const CapturedBody(null, false)
+ : captureBody(
+ response?.body,
+ config,
+ sourceTruncated: response?.bodyTruncated ?? false,
+ );
+ final error = log.error;
+ return log.copyWith(
+ method: boundedText(log.method, 32),
+ url: previous?.url == log.url ? log.url : captureUrl(log.url, config),
+ request: keepRequest
+ ? request
+ : request == null
+ ? null
+ : RequestDetails(
+ headers: captureHeaders(request.headers, config),
+ body: requestBody.text,
+ contentType: boundedText(request.contentType, 256),
+ bodyTruncated: request.bodyTruncated || requestBody.truncated,
+ ),
+ response: keepResponse
+ ? response
+ : response == null
+ ? null
+ : ResponseDetails(
+ statusCode: response.statusCode,
+ headers: captureHeaders(response.headers, config),
+ body: responseBody.text,
+ timestamp: response.timestamp,
+ bodyTruncated: response.bodyTruncated || responseBody.truncated,
+ ),
+ error: error == null
+ ? null
+ : ErrorDetails(
+ errorMessage: boundedText(
+ error.errorMessage,
+ config.maxMessageLength,
+ ),
+ errorType: error.errorType == null
+ ? null
+ : boundedText(error.errorType!, 128),
+ stackTrace: error.stackTrace == null
+ ? null
+ : boundedText(error.stackTrace!, config.maxMessageLength),
+ ),
+ );
+}
diff --git a/lib/src/core/log_export.dart b/lib/src/core/log_export.dart
new file mode 100644
index 0000000..250b8a1
--- /dev/null
+++ b/lib/src/core/log_export.dart
@@ -0,0 +1,221 @@
+import 'dart:convert';
+import 'dart:math' as math;
+import 'package:flutter/foundation.dart';
+
+import 'capture.dart';
+import 'log_model.dart';
+
+/// Exports sanitized, bounded previews. It never sends network requests.
+class EasyDebugExport {
+ static const _encoder = JsonEncoder.withIndent(' ');
+
+ static Future harAsync(
+ Iterable logs, {
+ EasyDebugConfig config = const EasyDebugConfig(),
+ }) => compute(
+ _encodeHar,
+ logs.map((log) => captureNetworkLog(log, config)).toList(),
+ debugLabel: 'EasyDebug HAR export',
+ );
+
+ static Future generalJsonAsync(Iterable logs) => compute(
+ _encodeGeneral,
+ logs.toList(),
+ debugLabel: 'EasyDebug log export',
+ );
+
+ static String har(
+ Iterable logs, {
+ EasyDebugConfig config = const EasyDebugConfig(),
+ }) =>
+ _capturedHar(logs.map((log) => captureNetworkLog(log, config)).toList());
+
+ static String _capturedHar(List entries) {
+ entries.sort((a, b) {
+ final time = a.timestamp.compareTo(b.timestamp);
+ return time == 0 ? a.id.compareTo(b.id) : time;
+ });
+ return _encoder.convert({
+ 'log': {
+ 'version': '1.2',
+ 'creator': {'name': 'easy_debug', 'version': '0.0.4'},
+ 'comment':
+ 'Sanitized captured previews. Wire sizes and transport phases are not recorded.',
+ 'entries': entries.map(_harEntry).toList(),
+ },
+ });
+ }
+
+ static String networkJson(
+ Iterable logs, {
+ EasyDebugConfig config = const EasyDebugConfig(),
+ }) => _encoder.convert(
+ logs.map((entry) {
+ final log = captureNetworkLog(entry, config);
+ return {
+ 'id': log.id,
+ 'timestamp': log.timestamp.toUtc().toIso8601String(),
+ 'method': log.method,
+ 'url': log.url,
+ 'statusCode': log.statusCode,
+ 'durationMs': log.durationMs,
+ 'pending': log.isPending,
+ 'cancelled': log.isCancelled,
+ 'request': {
+ 'headers': log.request?.headers,
+ 'body': log.request?.body,
+ 'truncated': log.request?.bodyTruncated ?? false,
+ },
+ 'response': {
+ 'headers': log.response?.headers,
+ 'body': log.response?.body,
+ 'truncated': log.response?.bodyTruncated ?? false,
+ },
+ if (log.error != null) 'error': log.error!.errorMessage,
+ };
+ }).toList(),
+ );
+
+ static String generalJson(Iterable logs) => _encoder.convert(
+ logs
+ .map(
+ (log) => {
+ 'timestamp': log.timestamp.toUtc().toIso8601String(),
+ 'level': log.level,
+ 'message': log.message,
+ },
+ )
+ .toList(),
+ );
+
+ /// A POSIX-shell reproduction template, with retained credentials masked.
+ static String curl(
+ NetworkLog entry, {
+ EasyDebugConfig config = const EasyDebugConfig(),
+ }) {
+ final log = captureNetworkLog(entry, config);
+ final args = [
+ 'curl',
+ '--request',
+ _quote(log.method),
+ '--url',
+ _quote(log.url),
+ ];
+ for (final header
+ in log.request?.headers.entries ??
+ const >[]) {
+ if (const {
+ 'host',
+ 'content-length',
+ 'transfer-encoding',
+ 'connection',
+ 'accept-encoding',
+ }.contains(header.key.toLowerCase())) {
+ continue;
+ }
+ args.addAll(['--header', _quote('${header.key}: ${header.value}')]);
+ }
+ final body = log.request?.body;
+ final truncated = log.request?.bodyTruncated ?? false;
+ final unavailable = const {
+ '',
+ '',
+ '',
+ '""',
+ }.contains(body);
+ if (body != null &&
+ body.toString().isNotEmpty &&
+ !truncated &&
+ !unavailable) {
+ args.addAll(['--data-raw', _quote(body.toString())]);
+ }
+ return '# Captured preview; replace redacted credentials before use.\n${truncated || unavailable ? '# Body omitted because the captured preview is truncated or unavailable.\n' : ''}${args.join(' ')}';
+ }
+
+ static String _quote(String value) => "'${value.replaceAll("'", "'\"'\"'")}'";
+
+ static List> _headers(Map? headers) =>
+ headers?.entries
+ .map((e) => {'name': e.key, 'value': e.value.toString()})
+ .toList() ??
+ [];
+
+ static String _header(Map? headers, String name) {
+ for (final entry
+ in headers?.entries ?? const >[]) {
+ if (entry.key.toLowerCase() == name) return entry.value.toString();
+ }
+ return '';
+ }
+
+ static Map _harEntry(NetworkLog log) {
+ final uri = Uri.tryParse(log.url);
+ final request = log.request;
+ final response = log.response;
+ final body = response?.body?.toString();
+ final duration = math.max(0, log.durationMs ?? 0);
+ return {
+ 'startedDateTime': log.timestamp.toUtc().toIso8601String(),
+ 'time': duration,
+ 'request': {
+ 'method': log.method,
+ 'url': log.url,
+ 'httpVersion': '',
+ 'cookies': [],
+ 'headers': _headers(request?.headers),
+ 'queryString': [
+ for (final entry
+ in uri?.queryParametersAll.entries ??
+ const >>[])
+ for (final value in entry.value)
+ {'name': entry.key, 'value': value},
+ ],
+ if (request?.body != null)
+ 'postData': {
+ 'mimeType': request!.contentType,
+ 'text': request.body.toString(),
+ },
+ 'headersSize': -1,
+ 'bodySize': -1,
+ },
+ 'response': {
+ 'status': log.statusCode,
+ 'statusText': '',
+ 'httpVersion': '',
+ 'cookies': [],
+ 'headers': _headers(response?.headers),
+ 'content': {
+ 'size': body == null ? 0 : utf8.encode(body).length,
+ 'mimeType': _header(response?.headers, 'content-type'),
+ 'text': ?body,
+ 'comment':
+ 'Size is the exported preview size, not the original wire size.',
+ },
+ 'redirectURL': _header(response?.headers, 'location'),
+ 'headersSize': -1,
+ 'bodySize': -1,
+ },
+ 'cache': {},
+ 'timings': {
+ 'send': 0,
+ 'wait': duration,
+ 'receive': 0,
+ 'comment':
+ 'Only total elapsed time is recorded; wait holds that aggregate.',
+ },
+ '_easyDebug': {
+ 'id': log.id,
+ 'pending': log.isPending,
+ 'cancelled': log.isCancelled,
+ 'requestBodyTruncated': request?.bodyTruncated ?? false,
+ 'responseBodyTruncated': response?.bodyTruncated ?? false,
+ if (log.error != null) 'error': log.error!.errorMessage,
+ },
+ };
+ }
+}
+
+String _encodeHar(List input) =>
+ EasyDebugExport._capturedHar(input);
+String _encodeGeneral(List logs) =>
+ EasyDebugExport.generalJson(logs);
diff --git a/lib/src/core/log_manager.dart b/lib/src/core/log_manager.dart
index 3dcb741..7977eba 100644
--- a/lib/src/core/log_manager.dart
+++ b/lib/src/core/log_manager.dart
@@ -1,119 +1,247 @@
+import 'dart:async';
+import 'dart:collection';
+
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
-import 'log_model.dart';
+
+import 'capture.dart';
import 'config_model.dart';
+import 'log_model.dart';
+import 'log_export.dart';
+import 'log_query.dart';
+
+/// Immutable snapshots are materialized on demand, once per observed revision.
+class _SnapshotNotifier extends ValueNotifier> {
+ _SnapshotNotifier(this._read) : super(const []);
+ final Iterable Function() _read;
+ List? _snapshot;
+ Timer? _notification;
+
+ @override
+ List get value => _snapshot ??= List.unmodifiable(_read());
+
+ @override
+ set value(List value) {
+ _snapshot = List.unmodifiable(value);
+ notifyListeners();
+ }
+
+ void invalidate() {
+ _snapshot = null;
+ notifyListeners();
+ }
+
+ @override
+ void notifyListeners() {
+ if (!hasListeners) return;
+ _notification ??= Timer(Duration.zero, () {
+ _notification = null;
+ super.notifyListeners();
+ });
+ }
+
+ @override
+ void removeListener(VoidCallback listener) {
+ super.removeListener(listener);
+ if (!hasListeners) {
+ _notification?.cancel();
+ _notification = null;
+ }
+ }
+
+ @override
+ void dispose() {
+ _notification?.cancel();
+ super.dispose();
+ }
+}
class EasyDebugManager {
static final EasyDebugManager _instance = EasyDebugManager._internal();
static const String _prefKeyEnvName = 'easy_debug_env_name';
-
factory EasyDebugManager() => _instance;
-
EasyDebugManager._internal();
- /// Configuration for the debug manager
- EasyDebugConfig config = const EasyDebugConfig();
-
- /// List of all network logs
- final ValueNotifier> logsNotifier = ValueNotifier([]);
-
- /// List of general logs (print/debugPrint)
- final ValueNotifier> generalLogsNotifier = ValueNotifier([]);
+ final configNotifier = ValueNotifier(const EasyDebugConfig());
+ EasyDebugConfig get config => configNotifier.value;
+ set config(EasyDebugConfig value) => updateConfig(value);
+ bool get enabled => config.enabled;
+ final pausedNotifier = ValueNotifier(false);
+ bool get isPaused => pausedNotifier.value;
+ bool get isRecording => enabled && !isPaused;
+
+ /// Stops new captures without deleting records. In-flight requests still finish.
+ void pauseRecording() {
+ if (enabled) pausedNotifier.value = true;
+ }
- /// Available environments
- List _availableEnvironments = [];
+ void resumeRecording() => pausedNotifier.value = false;
+
+ final _logs = {};
+ final _generalLogs = ListQueue();
+ late final _networkNotifier = _SnapshotNotifier(
+ () => _logs.values.toList().reversed,
+ );
+ late final _generalNotifier = _SnapshotNotifier(
+ () => _generalLogs,
+ );
+ ValueNotifier> get logsNotifier => _networkNotifier;
+ ValueNotifier> get generalLogsNotifier => _generalNotifier;
+ final currentEnvNotifier = ValueNotifier(null);
+ List _availableEnvironments = const [];
List get availableEnvironments => _availableEnvironments;
-
- /// Current selected environment
- final ValueNotifier currentEnvNotifier = ValueNotifier(null);
-
- /// Initialize the manager with available environments
- Future init({required List environments}) async {
- // Intercept debugPrint
- final originalDebugPrint = debugPrint;
- debugPrint = (String? message, {int? wrapWidth}) {
- if (message != null) {
- addGeneralLog(message);
- }
- originalDebugPrint(message, wrapWidth: wrapWidth);
- };
-
- _availableEnvironments = environments;
- if (_availableEnvironments.isEmpty) return;
-
- // Load persisted environment
+ DebugPrintCallback? _originalDebugPrint;
+ DebugPrintCallback? _captureDebugPrint;
+
+ /// Installs output capture once. Environments are optional for log-only usage.
+ Future init({List environments = const []}) async {
+ if (_captureDebugPrint == null) {
+ final original = debugPrint;
+ _originalDebugPrint = original;
+ _captureDebugPrint = (String? message, {int? wrapWidth}) {
+ if (message != null) addGeneralLog(message);
+ original(message, wrapWidth: wrapWidth);
+ };
+ debugPrint = _captureDebugPrint!;
+ }
+ _availableEnvironments = List.unmodifiable(environments);
+ if (environments.isEmpty) {
+ currentEnvNotifier.value = null;
+ return;
+ }
final prefs = await SharedPreferences.getInstance();
final savedName = prefs.getString(_prefKeyEnvName);
-
- AppEnvironment? targetEnv;
-
- if (savedName != null) {
- targetEnv = _availableEnvironments.firstWhere(
- (e) => e.name == savedName,
- orElse: () => _availableEnvironments.firstWhere(
- (e) => e.isDefault,
- orElse: () => _availableEnvironments.first,
- ),
- );
- } else {
- // Default to the one marked as default, or the first one
- targetEnv = _availableEnvironments.firstWhere(
+ currentEnvNotifier.value = environments.firstWhere(
+ (e) => e.name == savedName,
+ orElse: () => environments.firstWhere(
(e) => e.isDefault,
- orElse: () => _availableEnvironments.first,
- );
- }
+ orElse: () => environments.first,
+ ),
+ );
+ }
- currentEnvNotifier.value = targetEnv;
+ /// Stops capture, releases retained logs and restores the hook we installed.
+ void shutdown() {
+ if (identical(debugPrint, _captureDebugPrint) &&
+ _originalDebugPrint != null) {
+ debugPrint = _originalDebugPrint!;
+ }
+ _captureDebugPrint = null;
+ _originalDebugPrint = null;
+ updateConfig(config.copyWith(enabled: false));
}
- /// Change the current environment and persist it
Future changeEnvironment(AppEnvironment env) async {
+ if (!_availableEnvironments.contains(env)) {
+ throw ArgumentError.value(env, 'env');
+ }
if (currentEnvNotifier.value == env) return;
-
- currentEnvNotifier.value = env;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefKeyEnvName, env.name);
+ currentEnvNotifier.value = env;
}
- /// Helper to get current base URL
String get currentBaseUrl => currentEnvNotifier.value?.baseUrl ?? '';
+ NetworkLog? getLog(String id) => _logs[id];
+
+ List queryNetworkLogs(NetworkLogQuery query) =>
+ List.unmodifiable(logsNotifier.value.where(query.matches));
+ List queryGeneralLogs(GeneralLogQuery query) =>
+ List.unmodifiable(generalLogsNotifier.value.where(query.matches));
+
+ String exportHar({NetworkLogQuery query = const NetworkLogQuery()}) =>
+ EasyDebugExport.har(queryNetworkLogs(query), config: config);
+ String exportNetworkJson({NetworkLogQuery query = const NetworkLogQuery()}) =>
+ EasyDebugExport.networkJson(queryNetworkLogs(query), config: config);
+ String exportGeneralJson({GeneralLogQuery query = const GeneralLogQuery()}) =>
+ EasyDebugExport.generalJson(queryGeneralLogs(query));
void updateConfig(EasyDebugConfig newConfig) {
- config = newConfig;
- _enforceMaxLogCount();
+ if (newConfig.maxLogCount < 0 ||
+ newConfig.maxGeneralLogCount < 0 ||
+ newConfig.maxBodyBytes < 0 ||
+ newConfig.maxMessageLength <= 0) {
+ throw ArgumentError(
+ 'Log limits must be non-negative; message length must be positive.',
+ );
+ }
+ configNotifier.value = newConfig;
+ if (!enabled) {
+ pausedNotifier.value = false;
+ clearLogs();
+ return;
+ }
+ _trim();
+ _logs.updateAll((_, log) => _capture(log));
+ final messages = _generalLogs.toList();
+ _generalLogs.clear();
+ for (final log in messages) {
+ _generalLogs.add(
+ GeneralLog(
+ timestamp: log.timestamp,
+ message: boundedText(log.message, config.maxMessageLength),
+ level: log.level,
+ ),
+ );
+ }
+ _publishNetwork();
+ _publishGeneral();
}
void addLog(NetworkLog log) {
- final currentLogs = List.from(logsNotifier.value);
- currentLogs.insert(0, log); // Add new logs to the top
- logsNotifier.value = currentLogs;
- _enforceMaxLogCount();
+ if (!isRecording || config.maxLogCount == 0) return;
+ _logs[log.id] = _capture(log);
+ _trim();
+ _publishNetwork();
}
void updateLog(NetworkLog updatedLog) {
- final currentLogs = List.from(logsNotifier.value);
- final index = currentLogs.indexWhere((l) => l.id == updatedLog.id);
- if (index != -1) {
- currentLogs[index] = updatedLog;
- logsNotifier.value = currentLogs;
- }
+ if (!enabled || !_logs.containsKey(updatedLog.id)) return;
+ _logs[updatedLog.id] = captureNetworkLog(
+ updatedLog,
+ config,
+ previous: _logs[updatedLog.id],
+ );
+ _publishNetwork();
}
- void addGeneralLog(String message) {
- final currentLogs = List.from(generalLogsNotifier.value);
- currentLogs.add(GeneralLog(timestamp: DateTime.now(), message: message));
- generalLogsNotifier.value = currentLogs;
- // We might want to enforce max count here too, but let's keep it simple for now or reuse logic
+ NetworkLog _capture(NetworkLog log) => captureNetworkLog(log, config);
+
+ void addGeneralLog(String message, {String? level}) {
+ if (!isRecording || config.maxGeneralLogCount == 0) return;
+ _generalLogs.add(
+ GeneralLog(
+ timestamp: DateTime.now(),
+ message: boundedText(message, config.maxMessageLength),
+ level: level == null ? null : boundedText(level, 32),
+ ),
+ );
+ _trim();
+ _publishGeneral();
}
void clearLogs() {
- logsNotifier.value = [];
- generalLogsNotifier.value = [];
+ _logs.clear();
+ _generalLogs.clear();
+ _publishNetwork();
+ _publishGeneral();
}
- void _enforceMaxLogCount() {
- if (logsNotifier.value.length > config.maxLogCount) {
- logsNotifier.value = logsNotifier.value.sublist(0, config.maxLogCount);
+ void _trim() {
+ while (_logs.length > config.maxLogCount) {
+ _logs.remove(_logs.keys.first);
+ }
+ while (_generalLogs.length > config.maxGeneralLogCount) {
+ _generalLogs.removeFirst();
}
}
+
+ void _publishNetwork() => _networkNotifier.invalidate();
+ void _publishGeneral() => _generalNotifier.invalidate();
+}
+
+/// Explicit application logging without a global print hook.
+class EasyDebug {
+ static void log(String message, {String? level}) =>
+ EasyDebugManager().addGeneralLog(message, level: level);
}
diff --git a/lib/src/core/log_model.dart b/lib/src/core/log_model.dart
index 79d1610..0586432 100644
--- a/lib/src/core/log_model.dart
+++ b/lib/src/core/log_model.dart
@@ -1,5 +1,15 @@
-/// Represents simple configuration entries for Easy Debug
+import 'package:flutter/foundation.dart';
+
+/// Resource limits and capture policy for Easy Debug.
class EasyDebugConfig {
+ /// Disabled by default in release builds. Controls capture and the overlay.
+ final bool enabled;
+ final int maxGeneralLogCount;
+ final int maxBodyBytes;
+ final int maxMessageLength;
+ final Set sensitiveHeaders;
+ final Set sensitiveKeys;
+
/// Whether to clear logs when navigation (push) happens.
/// Default: false
final bool clearOnNavigation;
@@ -9,13 +19,47 @@ class EasyDebugConfig {
final int maxLogCount;
const EasyDebugConfig({
+ this.enabled = !kReleaseMode,
+ this.maxGeneralLogCount = 200,
+ this.maxBodyBytes = 65536,
+ this.maxMessageLength = 4000,
+ this.sensitiveHeaders = const {
+ 'authorization',
+ 'proxy-authorization',
+ 'cookie',
+ 'set-cookie',
+ 'x-api-key',
+ },
+ this.sensitiveKeys = const {
+ 'password',
+ 'token',
+ 'access_token',
+ 'refresh_token',
+ 'secret',
+ 'api_key',
+ },
this.clearOnNavigation = false,
this.maxLogCount = 50,
- });
+ }) : assert(maxLogCount >= 0),
+ assert(maxGeneralLogCount >= 0),
+ assert(maxBodyBytes >= 0),
+ assert(maxMessageLength > 0);
+
+ EasyDebugConfig copyWith({bool? enabled}) => EasyDebugConfig(
+ enabled: enabled ?? this.enabled,
+ clearOnNavigation: clearOnNavigation,
+ maxLogCount: maxLogCount,
+ maxGeneralLogCount: maxGeneralLogCount,
+ maxBodyBytes: maxBodyBytes,
+ maxMessageLength: maxMessageLength,
+ sensitiveHeaders: sensitiveHeaders,
+ sensitiveKeys: sensitiveKeys,
+ );
}
/// Represents a single network request/response log
class NetworkLog {
+ static int _sequence = 0;
final String id;
final DateTime timestamp;
final String method;
@@ -26,13 +70,16 @@ class NetworkLog {
ErrorDetails? error;
int? durationMs;
+ bool isComplete = false;
+ bool isCancelled = false;
+ bool get isPending => !isComplete && error == null && !isCancelled;
NetworkLog({
String? id,
required this.timestamp,
required this.method,
required this.url,
- }) : id = id ?? DateTime.now().microsecondsSinceEpoch.toString();
+ }) : id = id ?? '${DateTime.now().microsecondsSinceEpoch}-${_sequence++}';
bool get isError =>
error != null ||
@@ -49,6 +96,8 @@ class NetworkLog {
ResponseDetails? response,
ErrorDetails? error,
int? durationMs,
+ bool? isComplete,
+ bool? isCancelled,
}) {
return NetworkLog(
id: id ?? this.id,
@@ -59,19 +108,28 @@ class NetworkLog {
..request = request ?? this.request
..response = response ?? this.response
..error = error ?? this.error
- ..durationMs = durationMs ?? this.durationMs;
+ ..durationMs = durationMs ?? this.durationMs
+ ..isComplete = isComplete ?? this.isComplete
+ ..isCancelled = isCancelled ?? this.isCancelled;
}
}
class RequestDetails {
+ final bool bodyTruncated;
final Map headers;
final dynamic body;
final String contentType;
- RequestDetails({this.headers = const {}, this.body, this.contentType = ''});
+ RequestDetails({
+ this.headers = const {},
+ this.body,
+ this.contentType = '',
+ this.bodyTruncated = false,
+ });
}
class ResponseDetails {
+ final bool bodyTruncated;
final int? statusCode;
final Map headers;
final dynamic body;
@@ -82,6 +140,7 @@ class ResponseDetails {
this.headers = const {},
this.body,
required this.timestamp,
+ this.bodyTruncated = false,
});
}
diff --git a/lib/src/core/log_query.dart b/lib/src/core/log_query.dart
new file mode 100644
index 0000000..b387d6b
--- /dev/null
+++ b/lib/src/core/log_query.dart
@@ -0,0 +1,89 @@
+import 'log_model.dart';
+
+enum NetworkLogStatus { all, success, error, pending, cancelled }
+
+/// Composable filters shared by the console and programmatic exports.
+class NetworkLogQuery {
+ const NetworkLogQuery({
+ this.search = '',
+ this.status = NetworkLogStatus.all,
+ this.method,
+ this.host,
+ this.statusCode,
+ this.minDurationMs,
+ }) : assert(minDurationMs == null || minDurationMs >= 0);
+
+ final String search;
+ final NetworkLogStatus status;
+ final String? method;
+ final String? host;
+ final int? statusCode;
+ final int? minDurationMs;
+
+ bool matches(NetworkLog log) {
+ if (method != null && log.method.toUpperCase() != method!.toUpperCase()) {
+ return false;
+ }
+ if (host != null &&
+ Uri.tryParse(log.url)?.host.toLowerCase() != host!.toLowerCase()) {
+ return false;
+ }
+ if (statusCode != null && log.statusCode != statusCode) return false;
+ if (minDurationMs != null &&
+ (log.durationMs == null || log.durationMs! < minDurationMs!)) {
+ return false;
+ }
+ final stateMatches = switch (status) {
+ NetworkLogStatus.all => true,
+ NetworkLogStatus.success =>
+ !log.isPending &&
+ !log.isCancelled &&
+ !log.isError &&
+ log.statusCode >= 200 &&
+ log.statusCode < 400,
+ NetworkLogStatus.error => log.isError && !log.isCancelled,
+ NetworkLogStatus.pending => log.isPending,
+ NetworkLogStatus.cancelled => log.isCancelled,
+ };
+ final needle = search.trim().toLowerCase();
+ return stateMatches &&
+ (needle.isEmpty ||
+ log.url.toLowerCase().contains(needle) ||
+ log.method.toLowerCase().contains(needle));
+ }
+}
+
+enum GeneralLogSeverity { debug, info, warning, error }
+
+final _severityCache = Expando();
+
+GeneralLogSeverity severityOf(GeneralLog log) =>
+ _severityCache[log] ??= _inferSeverity(log);
+
+GeneralLogSeverity _inferSeverity(GeneralLog log) {
+ final level = log.level?.toUpperCase();
+ if (level == 'ERROR' || level == 'FATAL') return GeneralLogSeverity.error;
+ if (level == 'WARN' || level == 'WARNING') return GeneralLogSeverity.warning;
+ if (level == 'DEBUG' || level == 'TRACE') return GeneralLogSeverity.debug;
+ if (level == 'INFO') return GeneralLogSeverity.info;
+ final message = log.message.toLowerCase();
+ if (message.contains('error') ||
+ message.contains('exception') ||
+ message.contains('fail')) {
+ return GeneralLogSeverity.error;
+ }
+ if (message.contains('warn')) return GeneralLogSeverity.warning;
+ if (message.contains('debug')) return GeneralLogSeverity.debug;
+ return GeneralLogSeverity.info;
+}
+
+class GeneralLogQuery {
+ const GeneralLogQuery({this.search = '', this.severity});
+ final String search;
+ final GeneralLogSeverity? severity;
+ bool matches(GeneralLog log) {
+ if (severity != null && severityOf(log) != severity) return false;
+ final needle = search.trim().toLowerCase();
+ return needle.isEmpty || log.message.toLowerCase().contains(needle);
+ }
+}
diff --git a/lib/src/network/dio_interceptor.dart b/lib/src/network/dio_interceptor.dart
index 4f592ff..a54314e 100644
--- a/lib/src/network/dio_interceptor.dart
+++ b/lib/src/network/dio_interceptor.dart
@@ -1,114 +1,79 @@
import 'package:dio/dio.dart';
+
import '../core/log_manager.dart';
import '../core/log_model.dart';
class EasyDebugDioInterceptor extends Interceptor {
- final Map _requestMap = {};
+ static const _idKey = 'easy_debug_id';
+ static const _timerKey = 'easy_debug_timer';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
- final requestId = DateTime.now().microsecondsSinceEpoch.toString();
- _requestMap[options.uri.toString()] = requestId;
-
- final log = NetworkLog(
- id: requestId,
- timestamp: DateTime.now(),
- method: options.method,
- url: options.uri.toString(),
- );
+ final manager = EasyDebugManager();
+ if (manager.isRecording && manager.config.maxLogCount > 0) {
+ final log =
+ NetworkLog(
+ timestamp: DateTime.now(),
+ method: options.method,
+ url: options.uri.toString(),
+ )
+ ..request = RequestDetails(
+ headers: options.headers,
+ body: options.data,
+ contentType: options.contentType ?? '',
+ );
+ manager.addLog(log);
+ options.extra[_idKey] = log.id;
+ options.extra[_timerKey] = Stopwatch()..start();
+ }
+ super.onRequest(options, handler);
+ }
- log.request = RequestDetails(
- headers: options.headers,
- body: options.data,
- contentType: options.contentType ?? '',
+ void _complete(
+ RequestOptions options, {
+ Response? response,
+ DioException? error,
+ }) {
+ final id = options.extra.remove(_idKey);
+ final timer = options.extra.remove(_timerKey);
+ if (timer is Stopwatch) timer.stop();
+ if (id is! String) return;
+ final manager = EasyDebugManager();
+ final log = manager.getLog(id);
+ if (log == null) return;
+ manager.updateLog(
+ log.copyWith(
+ response: response == null
+ ? null
+ : ResponseDetails(
+ statusCode: response.statusCode,
+ headers: response.headers.map,
+ body: response.data,
+ timestamp: DateTime.now(),
+ ),
+ error: error == null
+ ? null
+ : ErrorDetails(
+ errorMessage: error.message ?? 'Network request failed',
+ errorType: error.type.name,
+ stackTrace: error.stackTrace.toString(),
+ ),
+ durationMs: timer is Stopwatch ? timer.elapsedMilliseconds : null,
+ isComplete: true,
+ isCancelled: error?.type == DioExceptionType.cancel,
+ ),
);
-
- EasyDebugManager().addLog(log);
-
- // Store custom property to track this request in onResponse/onError if needed,
- // though we can't easily modify RequestOptions to store arbitrary data in a clean way
- // without using `extra`.
- options.extra['easy_debug_id'] = requestId;
- options.extra['easy_debug_start_time'] =
- DateTime.now().millisecondsSinceEpoch;
-
- super.onRequest(options, handler);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
- final requestId = response.requestOptions.extra['easy_debug_id'] as String?;
- final startTime =
- response.requestOptions.extra['easy_debug_start_time'] as int?;
-
- // We need to find the log in Manager. Since Manager holds a list, we technically should
- // have a better lookup mechanism than iterating, but for debug purposes and small list size (50),
- // iteration is fine. However, since we re-create the list in Manager, we should find the reference.
-
- // Actually, simple way: we already added the log object. We should probably keep a reference to it?
- // But `onRequest` creates it. `onResponse` is separate.
- // Ideally Manager should have `getLogById` or `updateLog`.
- // For now let's iterate to find the partial log.
-
- // BETTER APPROACH: Manager exposes logs. We can find by ID.
- final logs = EasyDebugManager().logsNotifier.value;
- try {
- final log = logs.firstWhere((element) => element.id == requestId);
-
- log.response = ResponseDetails(
- statusCode: response.statusCode,
- headers: response.headers.map,
- body: response.data,
- timestamp: DateTime.now(),
- );
-
- if (startTime != null) {
- log.durationMs = DateTime.now().millisecondsSinceEpoch - startTime;
- }
-
- EasyDebugManager().updateLog(log);
- } catch (e) {
- // Log might have been cleared or not found
- }
-
+ _complete(response.requestOptions, response: response);
super.onResponse(response, handler);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
- final requestId = err.requestOptions.extra['easy_debug_id'] as String?;
- final startTime = err.requestOptions.extra['easy_debug_start_time'] as int?;
-
- final logs = EasyDebugManager().logsNotifier.value;
- try {
- final log = logs.firstWhere((element) => element.id == requestId);
-
- log.error = ErrorDetails(
- errorMessage: err.message ?? 'Unknown Error',
- errorType: err.type.toString(),
- errorObject: err.error,
- stackTrace: err.stackTrace.toString(),
- );
-
- // Also capture response if available in error (e.g. 404/500)
- if (err.response != null) {
- log.response = ResponseDetails(
- statusCode: err.response?.statusCode,
- headers: err.response?.headers.map ?? {},
- body: err.response?.data,
- timestamp: DateTime.now(),
- );
- }
-
- if (startTime != null) {
- log.durationMs = DateTime.now().millisecondsSinceEpoch - startTime;
- }
-
- EasyDebugManager().updateLog(log);
- } catch (e) {
- // Log not found
- }
-
+ _complete(err.requestOptions, response: err.response, error: err);
super.onError(err, handler);
}
}
diff --git a/lib/src/network/easy_debug_http_client.dart b/lib/src/network/easy_debug_http_client.dart
index 772a781..8d0d280 100644
--- a/lib/src/network/easy_debug_http_client.dart
+++ b/lib/src/network/easy_debug_http_client.dart
@@ -1,93 +1,145 @@
import 'dart:async';
import 'dart:convert';
+import 'dart:math' as math;
+
import 'package:http/http.dart' as http;
+
import '../core/log_manager.dart';
import '../core/log_model.dart';
-/// A wrapper client for the `http` package that logs network activity to `EasyDebugManager`.
-///
-/// Usage:
-/// ```dart
-/// final client = EasyDebugHttpClient(http.Client());
-/// final response = await client.get(Uri.parse('https://example.com'));
-/// ```
+/// Passes through response chunks, pause/resume, errors and cancellation.
+/// Only a bounded body prefix is retained for inspection.
class EasyDebugHttpClient extends http.BaseClient {
- final http.Client _inner;
-
EasyDebugHttpClient(this._inner);
+ final http.Client _inner;
@override
Future send(http.BaseRequest request) async {
- final startTime = DateTime.now();
- final requestId = startTime.millisecondsSinceEpoch.toString();
-
- // 1. Log Request
- final requestHeaders = {};
- request.headers.forEach((k, v) => requestHeaders[k] = v);
-
- String? requestBody;
- if (request is http.Request && request.body.isNotEmpty) {
- requestBody = request.body;
+ final manager = EasyDebugManager();
+ if (!manager.isRecording || manager.config.maxLogCount == 0) {
+ return _inner.send(request);
}
-
- final networkLog =
+ final timer = Stopwatch()..start();
+ Object? requestBody;
+ var requestBodyTruncated = false;
+ if (request is http.Request) {
+ final bytes = request.bodyBytes;
+ final limit = manager.config.maxBodyBytes;
+ requestBodyTruncated = bytes.length > limit;
+ final prefix = bytes.take(limit).toList(growable: false);
+ try {
+ requestBody = request.encoding.name == 'utf-8'
+ ? utf8.decode(prefix, allowMalformed: true)
+ : request.encoding.decode(prefix);
+ } catch (_) {
+ requestBody = '';
+ }
+ } else {
+ requestBody = '';
+ }
+ final log =
NetworkLog(
- id: requestId,
- url: request.url.toString(),
+ timestamp: DateTime.now(),
method: request.method,
- timestamp: startTime,
+ url: request.url.toString(),
)
..request = RequestDetails(
- headers: requestHeaders,
- body: _tryFormatJson(requestBody),
+ headers: request.headers,
+ body: requestBody,
+ bodyTruncated: requestBodyTruncated,
contentType: request.headers['content-type'] ?? '',
);
-
- EasyDebugManager().addLog(networkLog);
+ manager.addLog(log);
+ final id = log.id;
+ void update({
+ ResponseDetails? response,
+ ErrorDetails? error,
+ bool complete = false,
+ bool cancelled = false,
+ }) {
+ final current = manager.getLog(id);
+ if (current == null) return;
+ manager.updateLog(
+ current.copyWith(
+ response: response,
+ error: error,
+ durationMs: timer.elapsedMilliseconds,
+ isComplete: complete,
+ isCancelled: cancelled,
+ ),
+ );
+ }
try {
- // 2. Send Request
final response = await _inner.send(request);
-
- // 3. Log Response (Streamed)
- // Note: We need to copy the stream to read it without consuming it from the caller's perspective,
- // OR we just wait for the bytes. For BaseClient.send, it returns a StreamedResponse.
- // Reading the stream here would consume it, breaking the caller.
- //
- // Solution: We transform the stream to tap into the data as it flows through.
-
- final responseBodyBytes = [];
- final originalStream = response.stream;
-
- final transformer = StreamTransformer, List>.fromHandlers(
- handleData: (data, sink) {
- responseBodyBytes.addAll(data);
- sink.add(data);
- },
- handleDone: (sink) {
- sink.close();
- // Stream is done, we have the full body bytes now.
- _logResponse(networkLog, response, responseBodyBytes, DateTime.now());
- },
- handleError: (error, stackTrace, sink) {
- sink.addError(error, stackTrace);
- // Log error if stream fails
- EasyDebugManager().updateLog(
- networkLog.copyWith(
+ update(
+ response: ResponseDetails(
+ statusCode: response.statusCode,
+ headers: response.headers,
+ timestamp: DateTime.now(),
+ ),
+ );
+ final bytes = [];
+ // Freeze the cap for this request; later config reductions also apply below.
+ final limit = manager.config.maxBodyBytes;
+ var truncated = false;
+ var ended = false;
+ final stream = Stream>.multi((controller) {
+ final subscription = response.stream.listen(
+ (chunk) {
+ if (manager.getLog(id) == null || !manager.enabled) {
+ bytes.clear();
+ } else {
+ final remaining = math.max(
+ 0,
+ math.min(limit, manager.config.maxBodyBytes) - bytes.length,
+ );
+ if (chunk.length > remaining) truncated = true;
+ bytes.addAll(chunk.take(remaining));
+ }
+ controller.addSync(chunk);
+ },
+ onError: (Object error, StackTrace stack) {
+ update(
error: ErrorDetails(
errorMessage: error.toString(),
- stackTrace: stackTrace.toString(),
+ stackTrace: stack.toString(),
),
- durationMs: DateTime.now().difference(startTime).inMilliseconds,
- ),
- );
- },
- );
-
- final newStream = originalStream.transform(transformer);
-
+ complete: true,
+ );
+ controller.addErrorSync(error, stack);
+ },
+ onDone: () {
+ ended = true;
+ timer.stop();
+ update(
+ response: ResponseDetails(
+ statusCode: response.statusCode,
+ headers: response.headers,
+ timestamp: DateTime.now(),
+ body: utf8.decode(bytes, allowMalformed: true),
+ bodyTruncated: truncated,
+ ),
+ complete: true,
+ );
+ bytes.clear();
+ controller.closeSync();
+ },
+ );
+ controller.onPause = subscription.pause;
+ controller.onResume = subscription.resume;
+ controller.onCancel = () {
+ if (!ended) {
+ timer.stop();
+ final current = manager.getLog(id);
+ if (current?.error == null) update(complete: true, cancelled: true);
+ }
+ bytes.clear();
+ return subscription.cancel();
+ };
+ });
return http.StreamedResponse(
- newStream,
+ stream,
response.statusCode,
contentLength: response.contentLength,
request: response.request,
@@ -96,61 +148,19 @@ class EasyDebugHttpClient extends http.BaseClient {
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
- } catch (e, stack) {
- // 4. Handle Transport Error
- EasyDebugManager().updateLog(
- networkLog.copyWith(
- error: ErrorDetails(
- errorMessage: e.toString(),
- stackTrace: stack.toString(),
- ),
- durationMs: DateTime.now().difference(startTime).inMilliseconds,
+ } catch (error, stack) {
+ timer.stop();
+ update(
+ error: ErrorDetails(
+ errorMessage: error.toString(),
+ stackTrace: stack.toString(),
),
+ complete: true,
);
rethrow;
}
}
- void _logResponse(
- NetworkLog log,
- http.StreamedResponse response,
- List bodyBytes,
- DateTime endTime,
- ) {
- String? responseBody;
- try {
- responseBody = utf8.decode(bodyBytes, allowMalformed: true);
- } catch (_) {
- responseBody = '';
- }
-
- final responseHeaders = {};
- response.headers.forEach((k, v) => responseHeaders[k] = v);
-
- EasyDebugManager().updateLog(
- log.copyWith(
- response: ResponseDetails(
- statusCode: response.statusCode,
- headers: responseHeaders,
- body: _tryFormatJson(responseBody),
- timestamp: endTime,
- ),
- durationMs: endTime.difference(log.timestamp).inMilliseconds,
- ),
- );
- }
-
- String? _tryFormatJson(String? body) {
- if (body == null) return null;
- try {
- final decoded = jsonDecode(body);
- const encoder = JsonEncoder.withIndent(' ');
- return encoder.convert(decoded);
- } catch (_) {
- return body;
- }
- }
-
@override
void close() {
_inner.close();
diff --git a/lib/src/ui/debug_copy_button.dart b/lib/src/ui/debug_copy_button.dart
new file mode 100644
index 0000000..70141a7
--- /dev/null
+++ b/lib/src/ui/debug_copy_button.dart
@@ -0,0 +1,84 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+
+/// Copy feedback remains visible when the console covers the host Scaffold.
+class DebugCopyButton extends StatefulWidget {
+ const DebugCopyButton({
+ super.key,
+ required this.tooltip,
+ required this.content,
+ this.icon = Icons.copy_outlined,
+ });
+ final String tooltip;
+ final FutureOr Function() content;
+ final IconData icon;
+ @override
+ State createState() => _DebugCopyButtonState();
+}
+
+class _DebugCopyButtonState extends State {
+ Timer? _reset;
+ bool _copied = false;
+ bool _failed = false;
+ bool _busy = false;
+ @override
+ void dispose() {
+ _reset?.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) => IconButton(
+ tooltip: _copied
+ ? 'Copied'
+ : _failed
+ ? 'Copy failed'
+ : widget.tooltip,
+ icon: _busy
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : Icon(
+ _copied
+ ? Icons.check
+ : _failed
+ ? Icons.error_outline
+ : widget.icon,
+ ),
+ onPressed: _busy
+ ? null
+ : () async {
+ setState(() => _busy = true);
+ try {
+ final content = await widget.content();
+ if (!mounted) return;
+ await Clipboard.setData(ClipboardData(text: content));
+ if (!mounted) return;
+ setState(() {
+ _copied = true;
+ _failed = false;
+ _busy = false;
+ });
+ } catch (_) {
+ if (!mounted) return;
+ setState(() {
+ _copied = false;
+ _failed = true;
+ _busy = false;
+ });
+ }
+ _reset?.cancel();
+ _reset = Timer(const Duration(seconds: 2), () {
+ if (mounted) {
+ setState(() {
+ _copied = false;
+ _failed = false;
+ });
+ }
+ });
+ },
+ );
+}
diff --git a/lib/src/ui/debug_theme.dart b/lib/src/ui/debug_theme.dart
new file mode 100644
index 0000000..f04f24b
--- /dev/null
+++ b/lib/src/ui/debug_theme.dart
@@ -0,0 +1,94 @@
+import 'package:flutter/material.dart';
+import '../core/log_model.dart';
+
+ThemeData debugTheme(Brightness brightness) => ThemeData(
+ useMaterial3: true,
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: const Color(0xFF0F766E),
+ brightness: brightness,
+ ),
+ visualDensity: VisualDensity.compact,
+);
+
+String formatTime(DateTime time) =>
+ '${time.hour.toString().padLeft(2, '0')}:'
+ '${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
+
+String logStatus(NetworkLog log) => log.isCancelled
+ ? 'Cancelled'
+ : log.error != null
+ ? 'Error'
+ : log.isPending
+ ? 'Pending'
+ : '${log.statusCode}';
+
+Color statusColor(BuildContext context, NetworkLog log) => log.isCancelled
+ ? Theme.of(context).colorScheme.onSurfaceVariant
+ : log.isError
+ ? Theme.of(context).colorScheme.error
+ : log.isPending
+ ? const Color(0xFFB7791F)
+ : Theme.of(context).colorScheme.primary;
+
+class DebugBadge extends StatelessWidget {
+ const DebugBadge(this.text, {super.key, this.color});
+ final String text;
+ final Color? color;
+ @override
+ Widget build(BuildContext context) {
+ final foreground = color ?? Theme.of(context).colorScheme.primary;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
+ decoration: BoxDecoration(
+ color: foreground.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Text(
+ text,
+ style: TextStyle(
+ color: foreground,
+ fontSize: 11,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ );
+ }
+}
+
+class DebugEmptyState extends StatelessWidget {
+ const DebugEmptyState({
+ super.key,
+ required this.icon,
+ required this.title,
+ required this.message,
+ });
+ final IconData icon;
+ final String title;
+ final String message;
+ @override
+ Widget build(BuildContext context) => Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 38, color: Theme.of(context).colorScheme.primary),
+ const SizedBox(height: 16),
+ Text(
+ title,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ message,
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+}
diff --git a/lib/src/ui/easy_debug_widget.dart b/lib/src/ui/easy_debug_widget.dart
index bc766fa..66fce46 100644
--- a/lib/src/ui/easy_debug_widget.dart
+++ b/lib/src/ui/easy_debug_widget.dart
@@ -1,101 +1,151 @@
-import 'dart:ui';
+import 'dart:math' as math;
+
import 'package:flutter/material.dart';
-import '../core/log_manager.dart';
+import 'package:flutter/services.dart';
+
import '../core/config_model.dart';
-import 'log_list_view.dart';
+import '../core/log_manager.dart';
+import '../core/log_model.dart';
+import '../core/log_query.dart';
+import 'debug_theme.dart';
import 'general_logs_view.dart';
+import 'log_detail_page.dart';
+import 'log_list_view.dart';
enum DebugViewMode { network, generalLogs, settings }
class EasyDebugWidget extends StatefulWidget {
- final Widget child;
-
const EasyDebugWidget({super.key, required this.child});
-
- static void close(BuildContext context) {
- final state = context.findAncestorStateOfType<_EasyDebugWidgetState>();
- state?.closeConsole();
- }
-
+ final Widget child;
+ static void close(BuildContext context) =>
+ context.findAncestorStateOfType<_EasyDebugWidgetState>()?.closeConsole();
@override
State createState() => _EasyDebugWidgetState();
}
class _EasyDebugWidgetState extends State {
- bool _isConsoleVisible = false;
- // Initial position for the floating button
- Offset _buttonPosition = const Offset(20, 100);
- LogFilter _currentFilter = LogFilter.all;
- DebugViewMode _viewMode = DebugViewMode.network;
+ bool _visible = false;
+ Offset _position = const Offset(20, 100);
+ DebugViewMode _mode = DebugViewMode.network;
+ NetworkLog? _selectedLog;
+ NetworkLogQuery _networkQuery = const NetworkLogQuery();
+ GeneralLogQuery _generalQuery = const GeneralLogQuery();
- void closeConsole() {
- if (mounted) {
- setState(() {
- _isConsoleVisible = false;
- });
- }
+ @override
+ void initState() {
+ super.initState();
+ EasyDebugManager().configNotifier.addListener(_onConfigChanged);
+ }
+
+ void _onConfigChanged() {
+ if (!EasyDebugManager().enabled) closeConsole();
}
@override
- Widget build(BuildContext context) {
- return Stack(
- children: [
- widget.child,
- Overlay(
- initialEntries: [
- OverlayEntry(
- builder: (context) => Stack(
- children: [
- if (_isConsoleVisible)
- Positioned.fill(
- child: Material(
- color: Colors.black54,
+ void dispose() {
+ EasyDebugManager().configNotifier.removeListener(_onConfigChanged);
+ super.dispose();
+ }
+
+ void closeConsole() => setState(() {
+ _visible = false;
+ _selectedLog = null;
+ });
+
+ @override
+ Widget build(BuildContext context) => _ConsoleOverlay(
+ child: ValueListenableBuilder(
+ valueListenable: EasyDebugManager().configNotifier,
+ child: widget.child,
+ builder: (context, config, child) {
+ if (!config.enabled) return child!;
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final padding = MediaQuery.paddingOf(context);
+ final maxX = math.max(0.0, constraints.maxWidth - 64);
+ final maxY = math.max(
+ padding.top,
+ constraints.maxHeight - padding.bottom - 64,
+ );
+ final position = Offset(
+ _position.dx.clamp(0.0, maxX),
+ _position.dy.clamp(padding.top, maxY),
+ );
+ return Stack(
+ children: [
+ child!,
+ if (!_visible)
+ Positioned(
+ left: position.dx,
+ top: position.dy,
+ child: GestureDetector(
+ onPanUpdate: (details) => setState(() {
+ _position = Offset(
+ (position.dx + details.delta.dx).clamp(0.0, maxX),
+ (position.dy + details.delta.dy).clamp(
+ padding.top,
+ maxY,
+ ),
+ );
+ }),
+ child: SizedBox(
+ width: 56,
+ height: 56,
+ child: IconButton.filled(
+ tooltip: 'Open Easy Debug',
+ icon: const Icon(Icons.bug_report_outlined),
+ onPressed: () => setState(() => _visible = true),
+ ),
+ ),
+ ),
+ ),
+ if (_visible)
+ Positioned.fill(
+ bottom: MediaQuery.viewInsetsOf(context).bottom,
+ child: CallbackShortcuts(
+ bindings: {
+ const SingleActivator(LogicalKeyboardKey.escape):
+ closeConsole,
+ },
+ child: Focus(
+ autofocus: true,
child: Stack(
children: [
- GestureDetector(
- onTap: () =>
- setState(() => _isConsoleVisible = false),
- child: Container(color: Colors.transparent),
+ Positioned.fill(
+ child: GestureDetector(
+ onTap: closeConsole,
+ child: const ColoredBox(
+ color: Color(0x66000000),
+ ),
+ ),
),
- Positioned(
- top: 100,
- left: 20,
- right: 20,
- bottom: 50,
- child: ClipRRect(
- borderRadius: BorderRadius.circular(16),
- child: BackdropFilter(
- filter: ImageFilter.blur(
- sigmaX: 10.0,
- sigmaY: 10.0,
+ SafeArea(
+ minimum: const EdgeInsets.all(12),
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxWidth: 760,
+ maxHeight: 840,
),
- child: Container(
- decoration: BoxDecoration(
- color: Colors.white.withValues(
- alpha: 0.7,
+ child: RepaintBoundary(
+ key: const ValueKey('easy_debug_panel'),
+ child: Theme(
+ data: debugTheme(
+ Theme.of(context).brightness,
),
- borderRadius: BorderRadius.circular(16),
- border: Border.all(
- color: Colors.white.withValues(
- alpha: 0.2,
- ),
- width: 1.5,
+ child: Material(
+ elevation: 12,
+ borderRadius: BorderRadius.circular(24),
+ clipBehavior: Clip.antiAlias,
+ child: _selectedLog == null
+ ? _buildConsole()
+ : LogDetailPage(
+ log: _selectedLog!,
+ onBack: () => setState(
+ () => _selectedLog = null,
+ ),
+ ),
),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(
- alpha: 0.1,
- ),
- blurRadius: 10,
- spreadRadius: 2,
- ),
- ],
- ),
- child: Column(
- children: [
- _buildHeader(),
- Expanded(child: _buildContent()),
- ],
),
),
),
@@ -105,254 +155,269 @@ class _EasyDebugWidgetState extends State {
),
),
),
- if (!_isConsoleVisible)
- Positioned(
- left: _buttonPosition.dx,
- top: _buttonPosition.dy,
- child: Draggable(
- feedback: _buildFloatingButton(isDragging: true),
- childWhenDragging: Container(),
- onDragEnd: (details) {
- setState(() {
- _buttonPosition = details.offset;
- });
- },
- child: GestureDetector(
- onTap: () {
- setState(() {
- _isConsoleVisible = true;
- });
- },
- child: _buildFloatingButton(),
- ),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ ),
+ );
+
+ Widget _buildConsole() => LayoutBuilder(
+ builder: (context, constraints) => Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 14, 8, 8),
+ child: Row(
+ children: [
+ if (constraints.maxWidth >= 360)
+ DecoratedBox(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(10),
+ child: Icon(
+ Icons.bug_report_outlined,
+ color: Theme.of(context).colorScheme.onPrimaryContainer,
+ ),
+ ),
+ ),
+ if (constraints.maxWidth >= 360) const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Easy Debug',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.titleMedium?.copyWith(
+ fontWeight: FontWeight.w800,
),
),
- ],
+ if (constraints.maxHeight >= 440)
+ Text(
+ 'Your app, under the lens',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ],
+ ),
),
- ),
- ],
- ),
- ],
- );
- }
-
- Widget _buildContent() {
- switch (_viewMode) {
- case DebugViewMode.network:
- return Column(
- children: [
- _buildFilterTabs(),
- Expanded(child: LogListView(filter: _currentFilter)),
- ],
- );
- case DebugViewMode.generalLogs:
- return const GeneralLogsView();
- case DebugViewMode.settings:
- return _buildSettingsView();
- }
- }
-
- Widget _buildFloatingButton({bool isDragging = false}) {
- return Material(
- color: Colors.transparent,
- elevation: isDragging ? 6 : 0,
- child: Container(
- width: 48,
- height: 48,
- decoration: BoxDecoration(
- color: Colors.blueAccent,
- shape: BoxShape.circle,
- boxShadow: [
- if (!isDragging)
- BoxShadow(
- color: Colors.black26,
- blurRadius: 4,
- offset: const Offset(0, 2),
+ ValueListenableBuilder(
+ valueListenable: EasyDebugManager().pausedNotifier,
+ builder: (_, paused, _) => IconButton(
+ tooltip: paused ? 'Resume capture' : 'Pause capture',
+ icon: Icon(
+ paused
+ ? Icons.play_circle_outline
+ : Icons.pause_circle_outline,
+ ),
+ onPressed: paused
+ ? EasyDebugManager().resumeRecording
+ : EasyDebugManager().pauseRecording,
+ ),
),
- ],
- ),
- child: const Icon(Icons.bug_report, color: Colors.white),
- ),
- );
- }
-
- Widget _buildHeader() {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(color: Colors.black.withValues(alpha: 0.05)),
+ IconButton(
+ tooltip: 'Clear logs',
+ onPressed: EasyDebugManager().clearLogs,
+ icon: const Icon(Icons.delete_sweep_outlined),
+ ),
+ IconButton(
+ tooltip: 'Close console',
+ onPressed: closeConsole,
+ icon: const Icon(Icons.close),
+ ),
+ ],
+ ),
),
- ),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- const Text(
- 'Easy Debug',
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
+ child: SizedBox(
+ width: double.infinity,
+ child: SegmentedButton(
+ showSelectedIcon: false,
+ segments: const [
+ ButtonSegment(
+ value: DebugViewMode.network,
+ label: Text('Network'),
+ ),
+ ButtonSegment(
+ value: DebugViewMode.generalLogs,
+ label: Text('Logs'),
+ ),
+ ButtonSegment(
+ value: DebugViewMode.settings,
+ label: Text('Settings'),
+ ),
+ ],
+ selected: {_mode},
+ onSelectionChanged: (values) =>
+ setState(() => _mode = values.first),
+ ),
),
- Container(
- decoration: BoxDecoration(
- color: Colors.grey.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(8),
+ ),
+ const Divider(height: 1),
+ Expanded(
+ child: switch (_mode) {
+ DebugViewMode.network => LogListView(
+ initialQuery: _networkQuery,
+ onQueryChanged: (query) => _networkQuery = query,
+ onSelect: (log) => setState(() => _selectedLog = log),
),
- padding: const EdgeInsets.all(2),
+ DebugViewMode.generalLogs => GeneralLogsView(
+ initialQuery: _generalQuery,
+ onQueryChanged: (query) => _generalQuery = query,
+ ),
+ DebugViewMode.settings => const _EnvironmentSettings(),
+ },
+ ),
+ if (constraints.maxHeight >= 440)
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
- _buildTabIcon(Icons.wifi, DebugViewMode.network),
- _buildTabIcon(Icons.notes, DebugViewMode.generalLogs),
- _buildTabIcon(Icons.settings, DebugViewMode.settings),
+ Icon(
+ Icons.circle,
+ size: 7,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(width: 7),
+ Expanded(
+ child: ValueListenableBuilder(
+ valueListenable: EasyDebugManager().pausedNotifier,
+ builder: (_, paused, _) =>
+ ValueListenableBuilder(
+ valueListenable:
+ EasyDebugManager().currentEnvNotifier,
+ builder: (_, env, _) => Text(
+ paused
+ ? 'Capture paused'
+ : env?.name ?? 'Live capture',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ),
+ ),
+ ),
+ Flexible(
+ child: Text(
+ 'Stored on this device',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ),
],
),
),
- IconButton(
- icon: const Icon(Icons.delete_outline),
- tooltip: "Clear Logs",
- onPressed: () {
- EasyDebugManager().clearLogs();
- },
- ),
- IconButton(
- icon: const Icon(Icons.close),
- onPressed: () => setState(() => _isConsoleVisible = false),
- ),
- ],
- ),
- );
+ ],
+ ),
+ );
+}
+
+/// MaterialApp.builder is above its Navigator, so controls need their own
+/// overlay for tooltips, selection handles and text-field menus.
+class _ConsoleOverlay extends StatefulWidget {
+ const _ConsoleOverlay({required this.child});
+ final Widget child;
+ @override
+ State<_ConsoleOverlay> createState() => _ConsoleOverlayState();
+}
+
+class _ConsoleOverlayState extends State<_ConsoleOverlay> {
+ late final _entry = OverlayEntry(builder: (_) => widget.child);
+ @override
+ void didUpdateWidget(_ConsoleOverlay oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ _entry.markNeedsBuild();
}
- Widget _buildTabIcon(IconData icon, DebugViewMode mode) {
- final isSelected = _viewMode == mode;
- return GestureDetector(
- onTap: () => setState(() => _viewMode = mode),
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
- decoration: BoxDecoration(
- color: isSelected ? Colors.white : Colors.transparent,
- borderRadius: BorderRadius.circular(6),
- boxShadow: isSelected
- ? [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.05),
- blurRadius: 2,
- ),
- ]
- : null,
- ),
- child: Icon(
- icon,
- size: 20,
- color: isSelected ? Colors.blueAccent : Colors.grey,
- ),
- ),
- );
+ @override
+ void dispose() {
+ _entry
+ ..remove()
+ ..dispose();
+ super.dispose();
}
- Widget _buildSettingsView() {
+ @override
+ Widget build(BuildContext context) => Overlay(initialEntries: [_entry]);
+}
+
+class _EnvironmentSettings extends StatelessWidget {
+ const _EnvironmentSettings();
+ @override
+ Widget build(BuildContext context) {
final manager = EasyDebugManager();
if (manager.availableEnvironments.isEmpty) {
- return const Center(
- child: Text(
- "No environments configured.\nCall EasyDebugManager().init() first.",
- textAlign: TextAlign.center,
- style: TextStyle(color: Colors.grey),
- ),
+ return const DebugEmptyState(
+ icon: Icons.dns_outlined,
+ title: 'Choose your environment',
+ message:
+ 'Add your API environments during initialization to switch between them here.',
);
}
-
return ValueListenableBuilder(
valueListenable: manager.currentEnvNotifier,
- builder: (context, currentEnv, _) {
- return ListView(
- children: [
- const Padding(
- padding: EdgeInsets.all(16.0),
- child: Text(
- "Select Environment",
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.bold,
- color: Colors.grey,
- ),
- ),
- ),
- ...manager.availableEnvironments.map((env) {
- final isSelected = currentEnv == env;
- return ListTile(
- title: Text(
- env.name,
- style: const TextStyle(fontWeight: FontWeight.w600),
- ),
- subtitle: Text(
- env.baseUrl,
- style: const TextStyle(fontSize: 12),
- ),
- trailing: isSelected
- ? const Icon(Icons.check_circle, color: Colors.blueAccent)
- : const Icon(Icons.circle_outlined, color: Colors.grey),
- onTap: () {
- manager.changeEnvironment(env);
- },
- );
- }),
- const Padding(
- padding: EdgeInsets.all(16.0),
- child: Text(
- "Note: Switching environment might require a manual app restart if your Dio instance is not reactive.",
- style: TextStyle(fontSize: 12, color: Colors.orange),
- ),
- ),
- ],
- );
- },
- );
- }
-
- Widget _buildFilterTabs() {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
- child: Row(
+ builder: (context, selected, _) => ListView(
+ padding: const EdgeInsets.all(16),
children: [
- _buildTabItem("All", LogFilter.all),
- const SizedBox(width: 8),
- _buildTabItem("Success", LogFilter.success),
- const SizedBox(width: 8),
- _buildTabItem("Error", LogFilter.error),
- ],
- ),
- );
- }
-
- Widget _buildTabItem(String label, LogFilter filter) {
- final bool isSelected = _currentFilter == filter;
- return Expanded(
- child: GestureDetector(
- onTap: () {
- setState(() {
- _currentFilter = filter;
- });
- },
- child: Container(
- padding: const EdgeInsets.symmetric(vertical: 8),
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: isSelected ? Colors.blueAccent : Colors.transparent,
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: isSelected
- ? Colors.blueAccent
- : Colors.grey.withValues(alpha: 0.3),
- ),
+ Text(
+ 'API ENVIRONMENT',
+ style: Theme.of(context).textTheme.labelSmall,
),
- child: Text(
- label,
- style: TextStyle(
- color: isSelected ? Colors.white : Colors.black87,
- fontWeight: FontWeight.w500,
- fontSize: 13,
+ const SizedBox(height: 12),
+ for (final env in manager.availableEnvironments)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Card.filled(
+ color: selected == env
+ ? Theme.of(context).colorScheme.primaryContainer
+ : null,
+ child: ListTile(
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 6,
+ ),
+ title: Text(
+ env.name,
+ style: const TextStyle(fontWeight: FontWeight.w600),
+ ),
+ subtitle: Text(env.baseUrl),
+ trailing: Icon(
+ selected == env
+ ? Icons.check_circle
+ : Icons.circle_outlined,
+ ),
+ onTap: () async {
+ try {
+ await manager.changeEnvironment(env);
+ } catch (_) {
+ if (context.mounted) {
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
+ const SnackBar(
+ content: Text(
+ 'Could not save environment. Please try again.',
+ ),
+ ),
+ );
+ }
+ }
+ },
+ ),
+ ),
),
+ const SizedBox(height: 12),
+ const Text(
+ 'Your app must listen for environment changes to update its network clients.',
),
- ),
+ ],
),
);
}
diff --git a/lib/src/ui/general_logs_view.dart b/lib/src/ui/general_logs_view.dart
index be03aed..94e301a 100644
--- a/lib/src/ui/general_logs_view.dart
+++ b/lib/src/ui/general_logs_view.dart
@@ -1,129 +1,216 @@
+import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import '../core/log_export.dart';
import '../core/log_manager.dart';
import '../core/log_model.dart';
+import '../core/log_query.dart';
+import 'debug_copy_button.dart';
+import 'debug_theme.dart';
-class GeneralLogsView extends StatelessWidget {
- const GeneralLogsView({super.key});
-
+class GeneralLogsView extends StatefulWidget {
+ const GeneralLogsView({
+ super.key,
+ this.initialQuery = const GeneralLogQuery(),
+ this.onQueryChanged,
+ });
+ final GeneralLogQuery initialQuery;
+ final ValueChanged? onQueryChanged;
@override
- Widget build(BuildContext context) {
- return ValueListenableBuilder>(
- valueListenable: EasyDebugManager().generalLogsNotifier,
- builder: (context, logs, child) {
- if (logs.isEmpty) {
- return const Center(
- child: Text(
- "No logs yet.\nTry debugPrint('header', wrapWidth: 1024) or EasyDebug.log()",
- textAlign: TextAlign.center,
- style: TextStyle(color: Colors.grey, fontSize: 12),
- ),
- );
- }
-
- // Auto-scroll to bottom behavior is nice for terminals,
- // but for now let's just stick to a reversed list like Network logs
- // (Newest on top is usually better for mobile inspection)
- final reversedLogs = logs.reversed.toList();
+ State createState() => _GeneralLogsViewState();
+}
- return ListView.builder(
- padding: EdgeInsets.zero, // Remove padding to let shading fill width
- itemCount: reversedLogs.length,
- itemBuilder: (context, index) {
- final log = reversedLogs[index];
- return _buildLogItem(context, log, index);
- },
- );
- },
+class _GeneralLogsViewState extends State {
+ late String _query = widget.initialQuery.search;
+ late GeneralLogSeverity? _severity = widget.initialQuery.severity;
+ late final _search = TextEditingController(text: _query);
+ Timer? _debounce;
+ void _change(VoidCallback change) {
+ setState(change);
+ widget.onQueryChanged?.call(
+ GeneralLogQuery(search: _query, severity: _severity),
);
}
- Widget _buildLogItem(BuildContext context, GeneralLog log, int index) {
- final isEven = index % 2 == 0;
- // Increased contrast from grey[50] to grey[200]
- final backgroundColor = isEven ? Colors.white : Colors.grey[200];
- final textColor = _getLogColor(log.message);
+ @override
+ void dispose() {
+ _debounce?.cancel();
+ _search.dispose();
+ super.dispose();
+ }
- return Material(
- color: backgroundColor,
- child: InkWell(
- onTap: () {
- _copyToClipboard(context, log);
- },
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Timestamp column
- SizedBox(
- width: 60,
- child: Text(
- _formatTime(log.timestamp),
- style: const TextStyle(
- fontSize: 11,
- color: Colors.grey,
- fontFamily: 'Courier',
- fontWeight: FontWeight.w500,
+ @override
+ Widget build(
+ BuildContext context,
+ ) => ValueListenableBuilder>(
+ valueListenable: EasyDebugManager().generalLogsNotifier,
+ builder: (context, allLogs, _) {
+ final selection = GeneralLogQuery(search: _query, severity: _severity);
+ final logs = allLogs.where(selection.matches).toList();
+ return CustomScrollView(
+ slivers: [
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
+ child: Row(
+ children: [
+ Expanded(
+ child: TextField(
+ controller: _search,
+ decoration: InputDecoration(
+ hintText: 'Search messages',
+ prefixIcon: const Icon(Icons.search),
+ isDense: true,
+ filled: true,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ ),
+ onChanged: (value) {
+ _debounce?.cancel();
+ _debounce = Timer(
+ const Duration(milliseconds: 150),
+ () => _change(() => _query = value),
+ );
+ },
+ ),
),
- ),
+ DebugCopyButton(
+ tooltip: 'Copy filtered logs as JSON',
+ icon: Icons.file_download_outlined,
+ content: () => EasyDebugExport.generalJsonAsync(logs),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SliverToBoxAdapter(
+ child: SizedBox(
+ height: 44,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ children: [
+ for (final severity in [
+ null,
+ ...GeneralLogSeverity.values,
+ ])
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: FilterChip(
+ label: Text(switch (severity) {
+ null => 'All',
+ GeneralLogSeverity.debug => 'Debug',
+ GeneralLogSeverity.info => 'Info',
+ GeneralLogSeverity.warning => 'Warn',
+ GeneralLogSeverity.error => 'Error',
+ }),
+ selected: _severity == severity,
+ showCheckmark: false,
+ onSelected: (_) => _change(() => _severity = severity),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (logs.isEmpty)
+ SliverFillRemaining(
+ hasScrollBody: false,
+ child: DebugEmptyState(
+ icon: Icons.terminal,
+ title: allLogs.isEmpty
+ ? 'A little more context'
+ : 'No matching messages',
+ message: allLogs.isEmpty
+ ? 'Your debugPrint and EasyDebug.log messages appear here.'
+ : 'Try another level or search term.',
),
- const SizedBox(width: 8),
- // Separator line
- Container(
- width: 1,
- height: 14,
- color: Colors.grey.withValues(alpha: 0.2),
- margin: const EdgeInsets.only(top: 2, right: 8),
+ )
+ else
+ SliverPadding(
+ padding: const EdgeInsets.all(16),
+ sliver: SliverList.builder(
+ itemCount: logs.length,
+ itemBuilder: (context, index) =>
+ _card(context, logs[logs.length - index - 1]),
),
- // Message column
- Expanded(
- child: Text(
+ ),
+ ],
+ );
+ },
+ );
+
+ Widget _card(BuildContext context, GeneralLog log) {
+ final severity = severityOf(log);
+ final color = severity == GeneralLogSeverity.error
+ ? Theme.of(context).colorScheme.error
+ : severity == GeneralLogSeverity.warning
+ ? Theme.of(context).brightness == Brightness.dark
+ ? const Color(0xFFF6C76A)
+ : const Color(0xFF8A4B00)
+ : Theme.of(context).colorScheme.primary;
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Card.filled(
+ margin: EdgeInsets.zero,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: () async {
+ await Clipboard.setData(ClipboardData(text: log.message));
+ if (context.mounted) {
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
+ const SnackBar(
+ content: Text('Log copied'),
+ duration: Duration(seconds: 1),
+ ),
+ );
+ }
+ },
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Icon(Icons.circle, size: 6, color: color),
+ const SizedBox(width: 7),
+ Expanded(
+ child: Text(
+ log.level ?? severity.name.toUpperCase(),
+ style: TextStyle(
+ fontSize: 10,
+ fontWeight: FontWeight.w700,
+ color: color,
+ ),
+ ),
+ ),
+ Text(
+ formatTime(log.timestamp),
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ const SizedBox(width: 8),
+ const Icon(Icons.copy_outlined, size: 12),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Text(
log.message,
- style: TextStyle(
+ maxLines: 8,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontFamily: 'monospace',
fontSize: 12,
- color: textColor,
- fontFamily: 'Courier',
- height: 1.3,
+ height: 1.5,
),
),
- ),
- ],
+ ],
+ ),
),
),
),
);
}
-
- Color _getLogColor(String message) {
- final lower = message.toLowerCase();
- if (lower.contains('error') ||
- lower.contains('exception') ||
- lower.contains('fail')) {
- return Colors.red[700]!;
- }
- if (lower.contains('warning') || lower.contains('warn')) {
- return Colors.orange[800]!;
- }
- if (lower.contains('info') || lower.contains('debug')) {
- return Colors.blue[700]!;
- }
- return Colors.black87;
- }
-
- void _copyToClipboard(BuildContext context, GeneralLog log) {
- Clipboard.setData(ClipboardData(text: log.message));
- ScaffoldMessenger.of(context).clearSnackBars();
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(
- content: Text('Log copied to clipboard'),
- duration: Duration(milliseconds: 600),
- behavior: SnackBarBehavior.floating,
- ),
- );
- }
-
- String _formatTime(DateTime dt) {
- return "${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}";
- }
}
diff --git a/lib/src/ui/log_detail_page.dart b/lib/src/ui/log_detail_page.dart
index 3a76fb1..a9199fe 100644
--- a/lib/src/ui/log_detail_page.dart
+++ b/lib/src/ui/log_detail_page.dart
@@ -1,240 +1,200 @@
-import 'package:flutter/material.dart';
import 'dart:convert';
-import '../core/log_model.dart';
-
+import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import '../core/log_manager.dart';
+import '../core/log_model.dart';
+import '../core/log_export.dart';
+import 'debug_copy_button.dart';
+import 'debug_theme.dart';
class LogDetailPage extends StatefulWidget {
+ const LogDetailPage({super.key, required this.log, this.onBack});
final NetworkLog log;
-
- const LogDetailPage({super.key, required this.log});
-
+ final VoidCallback? onBack;
@override
State createState() => _LogDetailPageState();
}
-class _LogDetailPageState extends State
- with SingleTickerProviderStateMixin {
- late TabController _tabController;
-
- @override
- void initState() {
- super.initState();
- _tabController = TabController(length: 2, vsync: this);
- }
-
- @override
- void dispose() {
- _tabController.dispose();
- super.dispose();
- }
+class _LogDetailPageState extends State {
+ int _tab = 0;
+ late NetworkLog _latest = widget.log;
- void _copyContent() {
- String content = "";
- if (_tabController.index == 0) {
- // Request
- final headers = widget.log.request?.headers;
- final body = widget.log.request?.body;
- content = "URL: ${widget.log.url}\nMethod: ${widget.log.method}\n";
- content += "Headers: ${_formatJson(headers)}\n";
- content += "Body: ${_formatJson(body)}";
- } else {
- // Response
- final headers = widget.log.response?.headers;
- final body = widget.log.response?.body;
- content = "Status: ${widget.log.statusCode}\n";
- content += "Headers: ${_formatJson(headers)}\n";
- content += "Body: ${_formatJson(body)}";
+ String _format(dynamic value) {
+ if (value == null) return 'No body';
+ if (value is Map || value is List) {
+ return const JsonEncoder.withIndent(' ').convert(value);
}
-
- Clipboard.setData(ClipboardData(text: content));
- ScaffoldMessenger.of(
- context,
- ).showSnackBar(const SnackBar(content: Text('Copied to clipboard')));
- }
-
- String _formatJson(dynamic data) {
- if (data == null) return "null";
try {
- if (data is Map || data is List) {
- return const JsonEncoder.withIndent(' ').convert(data);
- }
- return data.toString();
+ return const JsonEncoder.withIndent(
+ ' ',
+ ).convert(jsonDecode(value.toString()));
} catch (_) {
- return data.toString();
+ return value.toString();
}
}
@override
- Widget build(BuildContext context) {
- final log = widget.log;
- return Scaffold(
- appBar: AppBar(
- title: Text('${log.method} Details'),
- elevation: 0,
- actions: [
- IconButton(
- icon: const Icon(Icons.copy),
- tooltip: "Copy Current Tab",
- onPressed: _copyContent,
- ),
- ],
- ),
- body: Column(
- children: [
- Container(
- color: Theme.of(context).primaryColor.withValues(alpha: 0.05),
- padding: const EdgeInsets.all(16.0),
- width: double.infinity,
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- SelectableText(
- log.url,
- style: const TextStyle(fontWeight: FontWeight.w500),
+ Widget build(BuildContext context) => Theme(
+ data: debugTheme(Theme.of(context).brightness),
+ child: ValueListenableBuilder>(
+ valueListenable: EasyDebugManager().logsNotifier,
+ builder: (context, logs, _) {
+ _latest = EasyDebugManager().getLog(widget.log.id) ?? _latest;
+ final log = _latest;
+ final request = _tab == 0;
+ final headers = request ? log.request?.headers : log.response?.headers;
+ final body = request ? log.request?.body : log.response?.body;
+ final truncated = request
+ ? log.request?.bodyTruncated ?? false
+ : log.response?.bodyTruncated ?? false;
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Request details'),
+ leading: widget.onBack == null
+ ? null
+ : IconButton(
+ tooltip: 'Back to requests',
+ icon: const Icon(Icons.arrow_back),
+ onPressed: widget.onBack,
+ ),
+ actions: [
+ DebugCopyButton(
+ tooltip: 'Copy as cURL',
+ icon: Icons.terminal,
+ content: () => EasyDebugExport.curl(
+ _latest,
+ config: EasyDebugManager().config,
),
- const SizedBox(height: 8),
- Row(
+ ),
+ IconButton(
+ tooltip: 'Copy details',
+ icon: const Icon(Icons.copy_outlined),
+ onPressed: () async {
+ final text =
+ 'URL: ${log.url}\nMethod: ${log.method}\nStatus: ${logStatus(log)}\n'
+ '${log.error == null ? '' : 'Error: ${log.error!.errorMessage}\n'}'
+ 'Headers: ${_format(headers)}\nBody: ${_format(body)}'
+ '${truncated ? '\n[Body truncated]' : ''}';
+ await Clipboard.setData(ClipboardData(text: text));
+ if (context.mounted) {
+ ScaffoldMessenger.maybeOf(context)?.showSnackBar(
+ const SnackBar(content: Text('Details copied')),
+ );
+ }
+ },
+ ),
+ ],
+ ),
+ body: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
- _buildTag(
- log.statusCode.toString(),
- _getStatusColor(log.statusCode),
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: [
+ DebugBadge(log.method),
+ DebugBadge(
+ logStatus(log),
+ color: statusColor(context, log),
+ ),
+ if (log.durationMs != null)
+ DebugBadge('${log.durationMs} ms'),
+ ],
+ ),
+ const SizedBox(height: 12),
+ SelectableText(
+ log.url,
+ minLines: 1,
+ maxLines: 3,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ ),
),
- const SizedBox(width: 8),
- Text('${log.durationMs ?? 0} ms'),
- const SizedBox(width: 8),
- Text(log.timestamp.toString().split('.')[0]),
],
),
- ],
- ),
- ),
- TabBar(
- controller: _tabController,
- labelColor: Colors.blue,
- unselectedLabelColor: Colors.grey,
- tabs: const [
- Tab(text: "Request"),
- Tab(text: "Response"),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: SegmentedButton(
+ showSelectedIcon: false,
+ segments: const [
+ ButtonSegment(value: 0, label: Text('Request')),
+ ButtonSegment(value: 1, label: Text('Response')),
+ ],
+ selected: {_tab},
+ onSelectionChanged: (value) =>
+ setState(() => _tab = value.first),
+ ),
+ ),
+ Expanded(
+ child: ListView(
+ padding: const EdgeInsets.all(16),
+ children: [
+ if (!request && log.isPending)
+ const Padding(
+ padding: EdgeInsets.only(bottom: 12),
+ child: Text('Waiting for response…'),
+ ),
+ if (!request && log.error != null)
+ _section(
+ context,
+ 'Error',
+ '${log.error!.errorMessage}\n${log.error!.stackTrace ?? ''}',
+ ),
+ _section(context, 'Headers', _format(headers)),
+ if (truncated)
+ Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(
+ 'Body truncated · capture limit reached',
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.error,
+ ),
+ ),
+ ),
+ _section(context, 'Body', _format(body)),
+ ],
+ ),
+ ),
],
),
- Expanded(
- child: TabBarView(
- controller: _tabController,
- children: [_buildRequestView(), _buildResponseView()],
- ),
+ );
+ },
+ ),
+ );
+
+ Widget _section(BuildContext context, String title, String text) => Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Text(
+ title.toUpperCase(),
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerLow,
+ borderRadius: BorderRadius.circular(12),
),
- ],
- ),
- );
- }
-
- Widget _buildRequestView() {
- final log = widget.log;
- return SingleChildScrollView(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _buildSectionTitle("Headers"),
- _buildJsonViewer(log.request?.headers),
- const SizedBox(height: 16),
- _buildSectionTitle("Body"),
- _buildJsonViewer(log.request?.body),
- ],
- ),
- );
- }
-
- Widget _buildResponseView() {
- final log = widget.log;
- if (log.isError && log.response == null) {
- return Padding(
- padding: const EdgeInsets.all(16.0),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Text(
- "Error Occurred",
- style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
+ child: SelectableText(
+ text,
+ style: const TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 12,
+ height: 1.5,
),
- const SizedBox(height: 8),
- Text(log.error?.errorMessage ?? "Unknown Error"),
- const SizedBox(height: 8),
- Text(
- log.error?.stackTrace ?? "",
- style: const TextStyle(fontSize: 10),
- ),
- ],
- ),
- );
- }
- return SingleChildScrollView(
- padding: const EdgeInsets.all(16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _buildSectionTitle("Headers"),
- _buildJsonViewer(log.response?.headers),
- const SizedBox(height: 16),
- _buildSectionTitle("Body"),
- _buildJsonViewer(log.response?.body),
- ],
- ),
- );
- }
-
- Widget _buildSectionTitle(String title) {
- return Padding(
- padding: const EdgeInsets.only(bottom: 8.0),
- child: Text(
- title,
- style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
- ),
- );
- }
-
- Widget _buildJsonViewer(dynamic data) {
- final formatted = _formatJson(data);
-
- return Container(
- width: double.infinity,
- padding: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: Colors.grey[100],
- borderRadius: BorderRadius.circular(8),
- border: Border.all(color: Colors.grey[300]!),
- ),
- child: SelectableText(
- formatted,
- style: const TextStyle(fontFamily: 'Courier', fontSize: 12),
- ),
- );
- }
-
- Color _getStatusColor(int code) {
- if (code >= 200 && code < 300) return Colors.green;
- if (code >= 300 && code < 400) return Colors.orange;
- if (code >= 400) return Colors.red;
- return Colors.grey;
- }
-
- Widget _buildTag(String text, Color color) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
- decoration: BoxDecoration(
- color: color.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(4),
- border: Border.all(color: color),
- ),
- child: Text(
- text,
- style: TextStyle(
- color: color,
- fontWeight: FontWeight.bold,
- fontSize: 12,
+ ),
),
- ),
- );
- }
+ ],
+ ),
+ );
}
diff --git a/lib/src/ui/log_list_view.dart b/lib/src/ui/log_list_view.dart
index 10404fe..2853475 100644
--- a/lib/src/ui/log_list_view.dart
+++ b/lib/src/ui/log_list_view.dart
@@ -1,163 +1,356 @@
+import 'dart:async';
import 'package:flutter/material.dart';
+import '../core/easy_debug_navigator_observer.dart';
+import '../core/log_export.dart';
import '../core/log_manager.dart';
import '../core/log_model.dart';
-import '../core/easy_debug_navigator_observer.dart';
-import 'easy_debug_widget.dart';
+import '../core/log_query.dart';
+import 'debug_copy_button.dart';
+import 'debug_theme.dart';
import 'log_detail_page.dart';
-enum LogFilter { all, success, error }
+enum LogFilter { all, success, error, pending, cancelled }
-class LogListView extends StatelessWidget {
+class LogListView extends StatefulWidget {
+ const LogListView({
+ super.key,
+ this.filter = LogFilter.all,
+ this.onSelect,
+ this.initialQuery,
+ this.onQueryChanged,
+ });
final LogFilter filter;
-
- const LogListView({super.key, this.filter = LogFilter.all});
-
+ final NetworkLogQuery? initialQuery;
+ final ValueChanged? onQueryChanged;
+ final ValueChanged? onSelect;
@override
- Widget build(BuildContext context) {
- return ValueListenableBuilder>(
- valueListenable: EasyDebugManager().logsNotifier,
- builder: (context, allLogs, child) {
- // Apply filter
- final logs = allLogs.where((log) {
- switch (filter) {
- case LogFilter.all:
- return true;
- case LogFilter.success:
- return !log.isError &&
- log.statusCode >= 200 &&
- log.statusCode < 400;
- case LogFilter.error:
- return log.isError || log.statusCode >= 400;
- }
- }).toList();
+ State createState() => _LogListViewState();
+}
- if (logs.isEmpty) {
- return const Center(child: Text("No logs found."));
- }
- return ListView.separated(
- itemCount: logs.length,
- separatorBuilder: (context, index) => const Divider(height: 1),
- itemBuilder: (context, index) {
- final log = logs[index];
- return _buildLogItem(context, log);
- },
- );
- },
- );
- }
+class _LogListViewState extends State {
+ late NetworkLogStatus _status =
+ widget.initialQuery?.status ??
+ NetworkLogStatus.values[widget.filter.index];
+ late String _query = widget.initialQuery?.search ?? '';
+ late String? _method = widget.initialQuery?.method;
+ late String? _host = widget.initialQuery?.host;
+ late int? _statusCode = widget.initialQuery?.statusCode;
+ late bool _slow = widget.initialQuery?.minDurationMs != null;
+ late bool _advanced = _method != null || _host != null || _slow;
+ late final _search = TextEditingController(text: _query);
+ Timer? _debounce;
+ NetworkLogQuery get _selection => NetworkLogQuery(
+ search: _query,
+ status: _status,
+ method: _method,
+ host: _host,
+ minDurationMs: _slow ? 1000 : null,
+ statusCode: _statusCode,
+ );
- Widget _buildLogItem(BuildContext context, NetworkLog log) {
- Color statusColor;
- final statusCode = log.statusCode;
- if (statusCode >= 200 && statusCode < 300) {
- statusColor = Colors.green;
- } else if (statusCode >= 300 && statusCode < 400) {
- statusColor = Colors.orange;
- } else if (statusCode >= 400) {
- statusColor = Colors.red;
- } else if (log.isError) {
- statusColor = Colors.red;
- } else {
- statusColor = Colors.grey;
- }
+ void _change(VoidCallback change) {
+ setState(change);
+ widget.onQueryChanged?.call(_selection);
+ }
- return ListTile(
- dense: true,
- onTap: () {
- EasyDebugWidget.close(context);
- final route = MaterialPageRoute(
- builder: (_) => LogDetailPage(log: log),
- );
+ void _reset() {
+ _debounce?.cancel();
+ _search.clear();
+ _change(() {
+ _query = '';
+ _status = NetworkLogStatus.all;
+ _method = null;
+ _host = null;
+ _slow = false;
+ _statusCode = null;
+ });
+ }
- // 1. Try using the observer (Best for getting the top-most context)
- if (EasyDebugNavigatorObserver.navigatorState != null) {
- EasyDebugNavigatorObserver.navigatorState!.push(route);
- return;
- }
+ @override
+ void dispose() {
+ _debounce?.cancel();
+ _search.dispose();
+ super.dispose();
+ }
- // 2. Fallback: Use the context from the overlay
- // We use rootNavigator: true to break out of any nested navigators
- try {
- Navigator.of(context, rootNavigator: true).push(route);
- } catch (e) {
- debugPrint(
- 'EasyDebug: Navigation failed. Please add EasyDebugNavigatorObserver to your MaterialApp/GoRouter.',
- );
- }
- },
- leading: Container(
- width: 50,
- alignment: Alignment.center,
- child: Text(
- log.method.toUpperCase(),
- style: TextStyle(
- fontWeight: FontWeight.bold,
- fontSize: 12,
- color: _getMethodColor(log.method),
- ),
- ),
- ),
- title: Text(
- log.url,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(fontSize: 13),
- ),
- subtitle: Row(
- children: [
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
- decoration: BoxDecoration(
- color: statusColor.withValues(alpha: 0.1),
- borderRadius: BorderRadius.circular(4),
- border: Border.all(color: statusColor, width: 0.5),
+ @override
+ Widget build(
+ BuildContext context,
+ ) => ValueListenableBuilder>(
+ valueListenable: EasyDebugManager().logsNotifier,
+ builder: (context, allLogs, _) {
+ final logs = allLogs.where(_selection.matches).toList();
+ final optionsSource = _advanced ? allLogs : const [];
+ final methods =
+ optionsSource.map((e) => e.method.toUpperCase()).toSet().toList()
+ ..sort();
+ final hosts =
+ optionsSource
+ .map((e) => Uri.tryParse(e.url)?.host ?? '')
+ .where((e) => e.isNotEmpty)
+ .toSet()
+ .toList()
+ ..sort();
+ if (_method != null && !methods.contains(_method)) methods.add(_method!);
+ if (_host != null && !hosts.contains(_host)) hosts.add(_host!);
+ return CustomScrollView(
+ slivers: [
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
+ child: TextField(
+ controller: _search,
+ decoration: InputDecoration(
+ hintText: 'Search URL or method',
+ prefixIcon: const Icon(Icons.search),
+ suffixIcon: IconButton(
+ tooltip: 'Reset filters',
+ icon: const Icon(Icons.clear),
+ onPressed: _reset,
+ ),
+ filled: true,
+ fillColor: Theme.of(context).colorScheme.surfaceContainerLow,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: BorderSide.none,
+ ),
+ isDense: true,
+ ),
+ onChanged: (value) {
+ _debounce?.cancel();
+ _debounce = Timer(
+ const Duration(milliseconds: 150),
+ () => _change(() => _query = value),
+ );
+ },
+ ),
),
- child: Text(
- statusCode > 0 ? statusCode.toString() : '---',
- style: TextStyle(
- color: statusColor,
- fontSize: 10,
- fontWeight: FontWeight.bold,
+ ),
+ SliverToBoxAdapter(
+ child: SizedBox(
+ height: 44,
+ child: ListView(
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ children: [
+ for (final status in NetworkLogStatus.values)
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: FilterChip(
+ label: Text(switch (status) {
+ NetworkLogStatus.all => 'All',
+ NetworkLogStatus.success => 'Success',
+ NetworkLogStatus.error => 'Error',
+ NetworkLogStatus.pending => 'Pending',
+ NetworkLogStatus.cancelled => 'Cancelled',
+ }),
+ selected: _status == status,
+ showCheckmark: false,
+ onSelected: (_) => _change(() => _status = status),
+ ),
+ ),
+ ],
),
),
),
- const SizedBox(width: 8),
- Text(
- DateFormatting.formatTimestamp(log.timestamp),
- style: const TextStyle(fontSize: 10, color: Colors.grey),
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ '${logs.length} / ${allLogs.length} requests',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ),
+ IconButton(
+ tooltip: 'Advanced filters',
+ isSelected: _advanced,
+ icon: const Icon(Icons.tune),
+ onPressed: () => setState(() => _advanced = !_advanced),
+ ),
+ DebugCopyButton(
+ tooltip: 'Copy filtered HAR',
+ icon: Icons.file_download_outlined,
+ content: () => EasyDebugExport.harAsync(
+ logs,
+ config: EasyDebugManager().config,
+ ),
+ ),
+ ],
+ ),
+ ),
),
+ if (_advanced)
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ child: DropdownMenu(
+ key: ValueKey('method-${_method ?? ''}'),
+ expandedInsets: EdgeInsets.zero,
+ initialSelection: _method ?? '',
+ label: const Text('Method'),
+ dropdownMenuEntries: [
+ const DropdownMenuEntry(
+ value: '',
+ label: 'Any method',
+ ),
+ for (final method in methods)
+ DropdownMenuEntry(value: method, label: method),
+ ],
+ onSelected: (value) => _change(
+ () => _method = value == '' ? null : value,
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: DropdownMenu(
+ key: ValueKey('host-${_host ?? ''}'),
+ expandedInsets: EdgeInsets.zero,
+ initialSelection: _host ?? '',
+ label: const Text('Host'),
+ menuHeight: 240,
+ dropdownMenuEntries: [
+ const DropdownMenuEntry(
+ value: '',
+ label: 'Any host',
+ ),
+ for (final host in hosts)
+ DropdownMenuEntry(value: host, label: host),
+ ],
+ onSelected: (value) => _change(
+ () => _host = value == '' ? null : value,
+ ),
+ ),
+ ),
+ ],
+ ),
+ Align(
+ alignment: Alignment.centerLeft,
+ child: FilterChip(
+ label: const Text('Slow requests · 1 s+'),
+ selected: _slow,
+ onSelected: (value) => _change(() => _slow = value),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (logs.isEmpty)
+ SliverFillRemaining(
+ hasScrollBody: false,
+ child: DebugEmptyState(
+ icon: Icons.travel_explore,
+ title: allLogs.isEmpty
+ ? 'Waiting for requests'
+ : 'No matching requests',
+ message: allLogs.isEmpty
+ ? 'Make a request in your app to inspect it here.'
+ : 'Try another filter or search term.',
+ ),
+ )
+ else
+ SliverPadding(
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
+ sliver: SliverList.builder(
+ itemCount: logs.length,
+ itemBuilder: (context, index) => _card(context, logs[index]),
+ ),
+ ),
],
- ),
- trailing: Text(
- log.durationMs != null ? '${log.durationMs} ms' : '',
- style: TextStyle(
- fontSize: 11,
- color: (log.durationMs ?? 0) > 1000
- ? Colors.orange
- : Colors.grey[600],
+ );
+ },
+ );
+
+ Widget _card(BuildContext context, NetworkLog log) {
+ final uri = Uri.tryParse(log.url);
+ return Padding(
+ key: ValueKey(log.id),
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Card.filled(
+ margin: EdgeInsets.zero,
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: () {
+ if (widget.onSelect != null) {
+ widget.onSelect!(log);
+ return;
+ }
+ final navigator =
+ EasyDebugNavigatorObserver.navigatorState ??
+ Navigator.maybeOf(context);
+ navigator?.push(
+ MaterialPageRoute(builder: (_) => LogDetailPage(log: log)),
+ );
+ },
+ child: Padding(
+ padding: const EdgeInsets.all(14),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ DebugBadge(log.method.toUpperCase()),
+ const Spacer(),
+ DebugBadge(
+ logStatus(log),
+ color: statusColor(context, log),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Text(
+ uri?.path.isNotEmpty == true ? uri!.path : log.url,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontWeight: FontWeight.w600,
+ fontSize: 14,
+ ),
+ ),
+ const SizedBox(height: 5),
+ Text(
+ uri?.host ?? log.url,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.bodySmall?.copyWith(
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Row(
+ children: [
+ Expanded(
+ child: Text(
+ formatTime(log.timestamp),
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ ),
+ Text(
+ log.durationMs == null
+ ? 'In progress'
+ : '${log.durationMs} ms',
+ style: Theme.of(context).textTheme.labelSmall,
+ ),
+ const SizedBox(width: 4),
+ const Icon(Icons.arrow_forward_ios, size: 10),
+ ],
+ ),
+ ],
+ ),
+ ),
),
),
);
}
-
- Color _getMethodColor(String method) {
- switch (method.toUpperCase()) {
- case 'GET':
- return Colors.blue;
- case 'POST':
- return Colors.purple;
- case 'PUT':
- return Colors.orange;
- case 'DELETE':
- return Colors.red;
- default:
- return Colors.black;
- }
- }
-}
-
-class DateFormatting {
- static String formatTimestamp(DateTime dt) {
- return "${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}";
- }
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 63577af..5699587 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,8 +1,8 @@
name: easy_debug
-description: A pure Dart in-app network debugger for Flutter. visualize Dio requests with a floating overlay, detailed inspection, and log management.
+description: An in-app Flutter debugger for Dio and http, with bounded logs, request inspection, environment switching and a floating console.
version: 0.0.4
# homepage: https://www.example.com
-repository: https://github.com/JasonBoolean/flutter_easy_debug
+repository: https://github.com/bobo928/flutter_easy_debug
environment:
sdk: ^3.10.1
@@ -21,3 +21,4 @@ dev_dependencies:
flutter_lints: ^6.0.0
flutter:
+ uses-material-design: true
diff --git a/screenshots/1.png b/screenshots/1.png
index d4eec56..4103163 100644
Binary files a/screenshots/1.png and b/screenshots/1.png differ
diff --git a/screenshots/2.png b/screenshots/2.png
index 0ce604a..92fc26e 100644
Binary files a/screenshots/2.png and b/screenshots/2.png differ
diff --git a/screenshots/3.png b/screenshots/3.png
index b365fbf..53cbac9 100644
Binary files a/screenshots/3.png and b/screenshots/3.png differ
diff --git a/screenshots/4.png b/screenshots/4.png
index 24e8eb5..514bee3 100644
Binary files a/screenshots/4.png and b/screenshots/4.png differ
diff --git a/screenshots/5.png b/screenshots/5.png
index 4af2a84..bc68254 100644
Binary files a/screenshots/5.png and b/screenshots/5.png differ
diff --git a/screenshots/6.png b/screenshots/6.png
new file mode 100644
index 0000000..4cc8a58
Binary files /dev/null and b/screenshots/6.png differ
diff --git a/screenshots/7.png b/screenshots/7.png
new file mode 100644
index 0000000..6b57d39
Binary files /dev/null and b/screenshots/7.png differ
diff --git a/test/manager_test.dart b/test/manager_test.dart
new file mode 100644
index 0000000..a679d00
--- /dev/null
+++ b/test/manager_test.dart
@@ -0,0 +1,188 @@
+import 'dart:convert';
+import 'package:easy_debug/easy_debug.dart';
+import 'package:easy_debug/src/core/capture.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+void main() {
+ final manager = EasyDebugManager();
+ setUp(() {
+ manager.shutdown();
+ manager.updateConfig(const EasyDebugConfig());
+ SharedPreferences.setMockInitialValues({});
+ });
+ tearDown(manager.shutdown);
+ NetworkLog log(String path) => NetworkLog(
+ timestamp: DateTime.now(),
+ method: 'GET',
+ url: 'https://test.local/$path',
+ );
+
+ test('evicts oldest logs and never resurrects cleared requests', () {
+ manager.updateConfig(const EasyDebugConfig(maxLogCount: 2));
+ final first = log('first');
+ manager.addLog(first);
+ manager.addLog(log('second'));
+ manager.addLog(log('third'));
+ expect(manager.getLog(first.id), isNull);
+ expect(manager.logsNotifier.value.map((e) => e.url), [
+ 'https://test.local/third',
+ 'https://test.local/second',
+ ]);
+ manager.clearLogs();
+ manager.updateLog(first.copyWith(isComplete: true));
+ expect(manager.logsNotifier.value, isEmpty);
+ });
+
+ test('body, headers and query credentials are clipped and redacted', () {
+ manager.updateConfig(const EasyDebugConfig(maxBodyBytes: 80));
+ final entry = log('data?token=secret')
+ ..request = RequestDetails(
+ headers: {
+ 'Authorization': 'Bearer secret',
+ 'Accept': 'application/json',
+ },
+ body: {
+ 'password': 'secret',
+ 'nested': {'token': 'secret'},
+ 'data': 'x' * 1000,
+ },
+ );
+ manager.addLog(entry);
+ final captured = manager.getLog(entry.id)!;
+ expect(captured.request!.headers['Authorization'], '[REDACTED]');
+ expect(captured.request!.body.toString(), isNot(contains('secret')));
+ expect(captured.url, isNot(contains('secret')));
+ expect(
+ utf8.encode(captured.request!.body.toString()).length,
+ lessThanOrEqualTo(80),
+ );
+ expect(captured.request!.bodyTruncated, isTrue);
+ });
+
+ test('unicode is clipped only at a complete UTF-8 character', () {
+ for (var limit = 0; limit < 20; limit++) {
+ final value = boundedText('你好🌍hello', limit);
+ expect(utf8.encode(value).length, lessThanOrEqualTo(limit));
+ expect(value, isNot(contains('\uFFFD')));
+ }
+ });
+
+ test(
+ 'structured capture stops visiting large collections at the byte cap',
+ () {
+ final body = List.generate(100000, (_) => {'value': 'x' * 100});
+ final captured = captureBody(
+ body,
+ const EasyDebugConfig(maxBodyBytes: 128),
+ );
+ expect(utf8.encode(captured.text!).length, lessThanOrEqualTo(128));
+ expect(captured.truncated, isTrue);
+ },
+ );
+
+ test('zero capacity and disabled capture retain no records', () {
+ manager.updateConfig(
+ const EasyDebugConfig(maxLogCount: 0, maxGeneralLogCount: 0),
+ );
+ manager.addLog(log('ignored'));
+ EasyDebug.log('ignored');
+ expect(manager.logsNotifier.value, isEmpty);
+ expect(manager.generalLogsNotifier.value, isEmpty);
+ manager.updateConfig(const EasyDebugConfig());
+ manager.addLog(log('captured'));
+ manager.updateConfig(const EasyDebugConfig(enabled: false));
+ manager.addLog(log('ignored'));
+ EasyDebug.log('ignored');
+ expect(manager.logsNotifier.value, isEmpty);
+ expect(manager.generalLogsNotifier.value, isEmpty);
+ });
+
+ test('reducing limits immediately trims retained records and previews', () {
+ manager.addLog(
+ log('body')
+ ..response = ResponseDetails(
+ timestamp: DateTime.now(),
+ body: 'x' * 1000,
+ ),
+ );
+ for (var i = 0; i < 20; i++) {
+ EasyDebug.log('long message $i');
+ }
+ manager.updateConfig(
+ const EasyDebugConfig(
+ maxBodyBytes: 10,
+ maxGeneralLogCount: 3,
+ maxMessageLength: 5,
+ ),
+ );
+ expect(
+ manager.logsNotifier.value.single.response!.body.toString().length,
+ lessThanOrEqualTo(10),
+ );
+ expect(manager.generalLogsNotifier.value.length, 3);
+ expect(manager.generalLogsNotifier.value.last.message.length, 5);
+ });
+
+ test('10,000 log burst has bounded retention and one notification', () async {
+ await Future.delayed(Duration.zero);
+ var notifications = 0;
+ void onChanged() {
+ notifications++;
+ }
+
+ manager.generalLogsNotifier.addListener(onChanged);
+ final watch = Stopwatch()..start();
+ for (var i = 0; i < 10000; i++) {
+ EasyDebug.log('burst $i');
+ }
+ watch.stop();
+ expect(manager.generalLogsNotifier.value.length, 200);
+ await Future.delayed(Duration.zero);
+ expect(notifications, 1);
+ manager.generalLogsNotifier.removeListener(onChanged);
+ debugPrint(
+ '10,000 logs: ${watch.elapsedMilliseconds} ms; retained 200; notifications $notifications',
+ );
+ });
+
+ test(
+ 'environment selection persists and unknown saved names fall back',
+ () async {
+ const prod = AppEnvironment(
+ name: 'Production',
+ baseUrl: 'https://prod.local',
+ isDefault: true,
+ );
+ const dev = AppEnvironment(
+ name: 'Development',
+ baseUrl: 'https://dev.local',
+ );
+ await manager.init(environments: [prod, dev]);
+ expect(manager.currentBaseUrl, prod.baseUrl);
+ await manager.changeEnvironment(dev);
+ await manager.init(environments: [prod, dev]);
+ expect(manager.currentBaseUrl, dev.baseUrl);
+ await manager.init(environments: [prod]);
+ expect(manager.currentBaseUrl, prod.baseUrl);
+ await expectLater(manager.changeEnvironment(dev), throwsArgumentError);
+ },
+ );
+
+ test(
+ 'shutdown restores debugPrint but does not overwrite a later hook',
+ () async {
+ final original = debugPrint;
+ await manager.init();
+ manager.shutdown();
+ expect(identical(debugPrint, original), isTrue);
+ await manager.init();
+ void later(String? message, {int? wrapWidth}) {}
+ debugPrint = later;
+ manager.shutdown();
+ expect(identical(debugPrint, later), isTrue);
+ debugPrint = original;
+ },
+ );
+}
diff --git a/test/network_test.dart b/test/network_test.dart
new file mode 100644
index 0000000..7a1a0bd
--- /dev/null
+++ b/test/network_test.dart
@@ -0,0 +1,292 @@
+import 'dart:async';
+import 'dart:convert';
+import 'package:dio/dio.dart';
+import 'package:easy_debug/easy_debug.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'regression_test.dart' show TestClient;
+
+class StubAdapter implements HttpClientAdapter {
+ StubAdapter(this.status);
+ final int status;
+ @override
+ Future fetch(
+ RequestOptions options,
+ Stream>? stream,
+ Future? cancelFuture,
+ ) async => ResponseBody.fromString(
+ '{"ok":true}',
+ status,
+ headers: {
+ 'content-type': ['application/json'],
+ },
+ );
+ @override
+ void close({bool force = false}) {}
+}
+
+void main() {
+ final manager = EasyDebugManager();
+ setUp(() {
+ manager.shutdown();
+ manager.updateConfig(
+ const EasyDebugConfig(maxLogCount: 500, maxBodyBytes: 128),
+ );
+ });
+ tearDown(manager.shutdown);
+
+ for (final status in [200, 404]) {
+ test(
+ 'Dio captures response $status without changing caller behavior',
+ () async {
+ final dio = Dio()..httpClientAdapter = StubAdapter(status);
+ dio.interceptors.add(EasyDebugDioInterceptor());
+ if (status == 200) {
+ expect(
+ (await dio.get('https://test.local/data')).statusCode,
+ 200,
+ );
+ } else {
+ await expectLater(
+ dio.get('https://test.local/data'),
+ throwsA(isA()),
+ );
+ }
+ final log = manager.logsNotifier.value.single;
+ expect(log.statusCode, status);
+ expect(log.isError, status == 404);
+ expect(log.isPending, isFalse);
+ expect(log.durationMs, isNotNull);
+ dio.close();
+ },
+ );
+ }
+
+ test(
+ 'concurrent http responses remain associated with their own requests',
+ () async {
+ final client = EasyDebugHttpClient(
+ TestClient((request) async {
+ await Future.delayed(const Duration(milliseconds: 1));
+ return http.StreamedResponse(
+ Stream.value(utf8.encode(request.url.path)),
+ 200,
+ );
+ }),
+ );
+ await Future.wait(
+ List.generate(200, (i) => client.get(Uri.https('test.local', '/$i'))),
+ );
+ final logs = manager.logsNotifier.value;
+ expect(logs.length, 200);
+ expect(logs.map((log) => log.id).toSet().length, 200);
+ for (final log in logs) {
+ expect(log.response!.body, Uri.parse(log.url).path);
+ expect(log.isPending, isFalse);
+ }
+ client.close();
+ },
+ );
+
+ test(
+ 'large http body reaches caller intact while preview stays bounded',
+ () async {
+ final body = 'x' * 100000;
+ final client = EasyDebugHttpClient(
+ TestClient(
+ (_) async => http.StreamedResponse(
+ Stream.fromIterable([
+ utf8.encode(body.substring(0, 50000)),
+ utf8.encode(body.substring(50000)),
+ ]),
+ 200,
+ ),
+ ),
+ );
+ final response = await client.get(Uri.https('test.local', '/large'));
+ expect(response.body, body);
+ final log = manager.logsNotifier.value.single;
+ expect(
+ utf8.encode(log.response!.body.toString()).length,
+ lessThanOrEqualTo(128),
+ );
+ expect(log.response!.bodyTruncated, isTrue);
+ client.close();
+ },
+ );
+
+ test('cancellation reaches source and marks log as cancelled', () async {
+ var cancelled = false;
+ final source = StreamController>(
+ onCancel: () {
+ cancelled = true;
+ },
+ );
+ final client = EasyDebugHttpClient(
+ TestClient((_) async => http.StreamedResponse(source.stream, 200)),
+ );
+ final response = await client.send(
+ http.Request('GET', Uri.https('test.local', '/stream')),
+ );
+ final subscription = response.stream.listen((_) {});
+ await subscription.cancel();
+ expect(cancelled, isTrue);
+ expect(manager.logsNotifier.value.single.isCancelled, isTrue);
+ expect(manager.logsNotifier.value.single.isPending, isFalse);
+ await source.close();
+ client.close();
+ });
+
+ test('headers are visible before the response body is consumed', () async {
+ final client = EasyDebugHttpClient(
+ TestClient((_) async => http.StreamedResponse(const Stream.empty(), 202)),
+ );
+ final response = await client.send(
+ http.Request('GET', Uri.https('test.local', '/delayed')),
+ );
+ expect(manager.logsNotifier.value.single.statusCode, 202);
+ expect(manager.logsNotifier.value.single.isPending, isTrue);
+ await response.stream.drain();
+ expect(manager.logsNotifier.value.single.isPending, isFalse);
+ client.close();
+ });
+
+ test('http pause and resume preserve source backpressure', () async {
+ var paused = false;
+ final source = StreamController>(
+ onPause: () {
+ paused = true;
+ },
+ onResume: () {
+ paused = false;
+ },
+ );
+ final client = EasyDebugHttpClient(
+ TestClient((_) async => http.StreamedResponse(source.stream, 200)),
+ );
+ final response = await client.send(
+ http.Request('GET', Uri.https('test.local', '/pause')),
+ );
+ final subscription = response.stream.listen((_) {});
+ subscription.pause();
+ expect(paused, isTrue);
+ subscription.resume();
+ expect(paused, isFalse);
+ await subscription.cancel();
+ await source.close();
+ client.close();
+ });
+
+ test(
+ 'transport errors propagate; late responses cannot restore cleared logs',
+ () async {
+ final client = EasyDebugHttpClient(
+ TestClient((_) async => throw StateError('offline')),
+ );
+ await expectLater(
+ client.get(Uri.https('test.local', '/offline')),
+ throwsStateError,
+ );
+ expect(manager.logsNotifier.value.single.isError, isTrue);
+ final source = StreamController>();
+ final delayed = EasyDebugHttpClient(
+ TestClient((_) async => http.StreamedResponse(source.stream, 200)),
+ );
+ final response = await delayed.send(
+ http.Request('GET', Uri.https('test.local', '/cleared')),
+ );
+ final finished = response.stream.drain();
+ manager.clearLogs();
+ source.add(utf8.encode('late'));
+ await source.close();
+ await finished;
+ expect(manager.logsNotifier.value, isEmpty);
+ client.close();
+ delayed.close();
+ },
+ );
+
+ test('disabled http capture is a transparent pass-through', () async {
+ manager.updateConfig(const EasyDebugConfig(enabled: false));
+ final client = EasyDebugHttpClient(
+ TestClient((_) async => http.StreamedResponse(Stream.value([65]), 200)),
+ );
+ expect((await client.get(Uri.https('test.local', '/data'))).body, 'A');
+ expect(manager.logsNotifier.value, isEmpty);
+ client.close();
+ });
+
+ test(
+ 'binary request bytes reach the inner client without decoder failures',
+ () async {
+ final client = EasyDebugHttpClient(
+ TestClient((request) async {
+ expect((request as http.Request).bodyBytes, [255, 254, 253]);
+ return http.StreamedResponse(const Stream.empty(), 200);
+ }),
+ );
+ final request = http.Request('POST', Uri.https('test.local', '/binary'))
+ ..bodyBytes = [255, 254, 253];
+ final response = await client.send(request);
+ await response.stream.drain();
+ expect(manager.logsNotifier.value.single.statusCode, 200);
+ client.close();
+ },
+ );
+
+ test('truncated JSON does not expose a credential in a raw prefix', () async {
+ final body = '{"password":"private-credential","padding":"${'x' * 2000}"}';
+ final client = EasyDebugHttpClient(
+ TestClient(
+ (_) async =>
+ http.StreamedResponse(Stream.value(utf8.encode(body)), 200),
+ ),
+ );
+ expect(
+ (await client.get(Uri.https('test.local', '/large-json'))).body,
+ body,
+ );
+ final log = manager.logsNotifier.value.single;
+ expect(
+ log.response!.body.toString(),
+ isNot(contains('private-credential')),
+ );
+ expect(log.response!.bodyTruncated, isTrue);
+ client.close();
+ });
+
+ test(
+ 'pausing skips new requests while an existing response still completes',
+ () async {
+ final source = StreamController>();
+ var calls = 0;
+ final client = EasyDebugHttpClient(
+ TestClient((_) async {
+ calls++;
+ return calls == 1
+ ? http.StreamedResponse(source.stream, 200)
+ : http.StreamedResponse(
+ Stream.value(utf8.encode('after pause')),
+ 200,
+ );
+ }),
+ );
+ final response = await client.send(
+ http.Request('GET', Uri.https('test.local', '/before')),
+ );
+ final done = response.stream.drain();
+ manager.pauseRecording();
+ expect(
+ (await client.get(Uri.https('test.local', '/during'))).body,
+ 'after pause',
+ );
+ source.add(utf8.encode('completed'));
+ await source.close();
+ await done;
+ expect(manager.logsNotifier.value.length, 1);
+ expect(manager.logsNotifier.value.single.response!.body, 'completed');
+ expect(manager.logsNotifier.value.single.isPending, isFalse);
+ client.close();
+ },
+ );
+}
diff --git a/test/performance_test.dart b/test/performance_test.dart
new file mode 100644
index 0000000..5133b79
--- /dev/null
+++ b/test/performance_test.dart
@@ -0,0 +1,50 @@
+import 'dart:convert';
+
+import 'package:easy_debug/easy_debug.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ final manager = EasyDebugManager();
+ setUp(() {
+ manager.shutdown();
+ manager.updateConfig(const EasyDebugConfig());
+ });
+ tearDown(manager.shutdown);
+
+ test(
+ 'sustained ingestion preserves bounded snapshots and batches notifications',
+ () async {
+ final samples = [];
+ for (var sample = 0; sample < 3; sample++) {
+ manager.clearLogs();
+ await Future.delayed(Duration.zero);
+ var notifications = 0;
+ void listener() {
+ notifications++;
+ }
+
+ manager.generalLogsNotifier.addListener(listener);
+ final watch = Stopwatch()..start();
+ for (var i = 0; i < 100000; i++) {
+ EasyDebug.log('event $i');
+ }
+ watch.stop();
+ samples.add(watch.elapsedMicroseconds);
+ final snapshot = manager.generalLogsNotifier.value;
+ expect(snapshot.length, 200);
+ expect(snapshot.last.message, 'event 99999');
+ await Future.delayed(Duration.zero);
+ expect(notifications, 1);
+ manager.generalLogsNotifier.removeListener(listener);
+ EasyDebug.log('next event');
+ expect(snapshot.last.message, 'event 99999');
+ expect(manager.generalLogsNotifier.value.last.message, 'next event');
+ }
+ final sorted = [...samples]..sort();
+ debugPrint(
+ 'BENCH ${jsonEncode({'case': '100000_general_logs', 'samples_us': samples, 'median_us': sorted[1], 'retained': 200, 'notifications_per_burst': 1})}',
+ );
+ },
+ );
+}
diff --git a/test/query_export_test.dart b/test/query_export_test.dart
new file mode 100644
index 0000000..c95c7af
--- /dev/null
+++ b/test/query_export_test.dart
@@ -0,0 +1,420 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:isolate';
+
+import 'package:easy_debug/easy_debug.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ final manager = EasyDebugManager();
+ setUp(() {
+ manager.shutdown();
+ manager.updateConfig(const EasyDebugConfig());
+ });
+ tearDown(manager.shutdown);
+ NetworkLog request(
+ String path, {
+ String method = 'GET',
+ int code = 200,
+ int duration = 120,
+ }) =>
+ NetworkLog(
+ timestamp: DateTime.utc(2026, 11, 3, 12),
+ method: method,
+ url: 'https://api.test.local$path',
+ )
+ ..request = RequestDetails(
+ headers: {'Content-Type': 'application/json'},
+ body: '{"ok":true}',
+ contentType: 'application/json',
+ )
+ ..response = ResponseDetails(
+ statusCode: code,
+ headers: {'content-type': 'application/json'},
+ body: '{"ok":true}',
+ timestamp: DateTime.now(),
+ )
+ ..isComplete = true
+ ..durationMs = duration;
+
+ test(
+ 'combined network queries filter method, host, status and slow requests',
+ () {
+ manager.addLog(
+ request('/orders', method: 'POST', code: 201, duration: 1200),
+ );
+ manager.addLog(request('/orders', duration: 10));
+ manager.addLog(request('/missing', code: 404, duration: 1500));
+ final query = NetworkLogQuery(
+ search: 'ORDERS',
+ method: 'post',
+ host: 'API.TEST.LOCAL',
+ statusCode: 201,
+ minDurationMs: 1000,
+ status: NetworkLogStatus.success,
+ );
+ expect(manager.queryNetworkLogs(query).length, 1);
+ expect(
+ manager
+ .queryNetworkLogs(
+ const NetworkLogQuery(status: NetworkLogStatus.error),
+ )
+ .single
+ .statusCode,
+ 404,
+ );
+ expect(
+ manager.queryNetworkLogs(
+ const NetworkLogQuery(host: 'different.local'),
+ ),
+ isEmpty,
+ );
+ expect(
+ manager.queryNetworkLogs(const NetworkLogQuery(method: 'DELETE')),
+ isEmpty,
+ );
+ },
+ );
+
+ test('pending and cancelled requests have independent filters', () {
+ final pending = NetworkLog(
+ timestamp: DateTime.now(),
+ method: 'GET',
+ url: 'https://test.local',
+ );
+ manager.addLog(pending);
+ manager.addLog(
+ request('/cancelled')
+ ..isCancelled = true
+ ..error = ErrorDetails(errorMessage: 'cancelled'),
+ );
+ expect(
+ manager
+ .queryNetworkLogs(
+ const NetworkLogQuery(status: NetworkLogStatus.pending),
+ )
+ .single
+ .id,
+ pending.id,
+ );
+ expect(
+ manager
+ .queryNetworkLogs(
+ const NetworkLogQuery(status: NetworkLogStatus.cancelled),
+ )
+ .length,
+ 1,
+ );
+ expect(
+ manager.queryNetworkLogs(
+ const NetworkLogQuery(status: NetworkLogStatus.error),
+ ),
+ isEmpty,
+ );
+ expect(
+ manager.queryNetworkLogs(const NetworkLogQuery(minDurationMs: 1000)),
+ isEmpty,
+ );
+ });
+
+ test(
+ 'HAR exports chronological, sanitized previews with required fields',
+ () {
+ final first = request(
+ '/data?tag=a&tag=b&token=query-secret',
+ method: 'POST',
+ );
+ first.request = RequestDetails(
+ contentType: 'application/json',
+ headers: {'Authorization': 'header-secret'},
+ body: {'password': 'body-secret'},
+ );
+ manager.addLog(first);
+ manager.addLog(
+ request(
+ '/later',
+ ).copyWith(timestamp: first.timestamp.add(const Duration(seconds: 1))),
+ );
+ final text = manager.exportHar();
+ final fixture = Platform.environment['EASY_DEBUG_EXPORT_FIXTURE'];
+ if (fixture != null) {
+ final file = File(fixture);
+ file.parent.createSync(recursive: true);
+ file.writeAsStringSync(text);
+ }
+ expect(text, isNot(contains('query-secret')));
+ expect(text, isNot(contains('header-secret')));
+ expect(text, isNot(contains('body-secret')));
+ final har = jsonDecode(text) as Map;
+ expect(har['log']['version'], '1.2');
+ final entries = har['log']['entries'] as List;
+ expect(
+ entries.first['startedDateTime'],
+ first.timestamp.toIso8601String(),
+ );
+ expect(
+ entries.first['request']['queryString']
+ .where((dynamic pair) => pair['name'] == 'tag')
+ .length,
+ 2,
+ );
+ expect(entries.first['request']['headersSize'], -1);
+ expect(entries.first['response']['bodySize'], -1);
+ expect(
+ entries.first['response']['content']['mimeType'],
+ 'application/json',
+ );
+ expect(entries.first['timings']['wait'], entries.first['time']);
+ for (final entry in entries) {
+ for (final key in [
+ 'startedDateTime',
+ 'time',
+ 'request',
+ 'response',
+ 'cache',
+ 'timings',
+ ]) {
+ expect((entry as Map).containsKey(key), isTrue, reason: key);
+ }
+ }
+ },
+ );
+
+ test(
+ 'exports reapply masking to caller-supplied records and URL credentials',
+ () {
+ final raw = request('/private').copyWith(
+ url: 'https://user:password-secret@test.local/path?token=query-secret',
+ );
+ raw.request = RequestDetails(
+ headers: {'Authorization': 'header-secret'},
+ body: {'token': 'body-secret'},
+ );
+ final har = EasyDebugExport.har([raw]);
+ final curl = EasyDebugExport.curl(raw);
+ for (final secret in [
+ 'password-secret',
+ 'query-secret',
+ 'header-secret',
+ 'body-secret',
+ ]) {
+ expect(har, isNot(contains(secret)));
+ expect(curl, isNot(contains(secret)));
+ }
+ },
+ );
+
+ test(
+ 'HAR preserves pending, cancellation and truncated preview metadata',
+ () {
+ manager.updateConfig(const EasyDebugConfig(maxBodyBytes: 16));
+ manager.addLog(
+ request('/long')
+ ..response = ResponseDetails(
+ body: 'x' * 1000,
+ timestamp: DateTime.now(),
+ statusCode: 200,
+ ),
+ );
+ final har = jsonDecode(manager.exportHar());
+ expect(
+ har['log']['entries'].single['_easyDebug']['responseBodyTruncated'],
+ isTrue,
+ );
+ expect(
+ har['log']['entries'].single['response']['content']['text'].length,
+ lessThanOrEqualTo(16),
+ );
+ final pending = NetworkLog(
+ timestamp: DateTime.now(),
+ method: 'GET',
+ url: 'https://test.local/pending',
+ );
+ final encoded = jsonDecode(EasyDebugExport.har([pending]));
+ expect(encoded['log']['entries'].single['_easyDebug']['pending'], isTrue);
+ expect(encoded['log']['entries'].single['response']['status'], 0);
+ expect(jsonDecode(EasyDebugExport.har([]))['log']['entries'], isEmpty);
+ },
+ );
+
+ test(
+ 'cURL safely preserves literal shell metacharacters without executing them',
+ () async {
+ final raw = request('/data', method: 'POST');
+ final body =
+ r"O'Reilly $(printf INJECTED)"
+ '\nsecond line';
+ raw.request = RequestDetails(
+ body: body,
+ headers: {'X-Trace': r"value' $(printf INJECTED)"},
+ );
+ final command = EasyDebugExport.curl(raw);
+ // Replace curl with a local function; no network request is sent.
+ final result = await Process.run('/bin/sh', [
+ '-c',
+ r'''curl() { printf '%s\0' "$@"; }
+''' +
+ command,
+ ]);
+ expect(result.exitCode, 0);
+ final args = (result.stdout as String).split('\u0000');
+ expect(args, contains(body));
+ expect(args, contains(r"X-Trace: value' $(printf INJECTED)"));
+ expect(args, contains('https://api.test.local/data'));
+ },
+ skip: Platform.isWindows,
+ );
+
+ test(
+ 'cURL omits truncated payloads instead of pretending they are complete',
+ () {
+ manager.updateConfig(const EasyDebugConfig(maxBodyBytes: 16));
+ manager.addLog(
+ request('/data', method: 'POST')
+ ..request = RequestDetails(body: 'x' * 100),
+ );
+ final command = EasyDebugExport.curl(
+ manager.logsNotifier.value.single,
+ config: manager.config,
+ );
+ expect(command, contains('Body omitted'));
+ expect(command, isNot(contains('--data-raw')));
+ },
+ );
+
+ test('async HAR export has the same content as synchronous export', () async {
+ manager.addLog(request('/data'));
+ expect(
+ jsonDecode(
+ await EasyDebugExport.harAsync(
+ manager.logsNotifier.value,
+ config: manager.config,
+ ),
+ ),
+ jsonDecode(manager.exportHar()),
+ );
+ });
+
+ test(
+ 'async export sanitizes native error objects before crossing isolates',
+ () async {
+ final port = ReceivePort();
+ try {
+ final raw = request('/native')
+ ..error = ErrorDetails(errorMessage: 'offline', errorObject: port);
+ final asyncHar = await EasyDebugExport.harAsync([raw]);
+ expect(jsonDecode(asyncHar), jsonDecode(EasyDebugExport.har([raw])));
+ } finally {
+ port.close();
+ }
+ },
+ );
+
+ test('headers use canonical values and redirect credentials are masked', () {
+ manager.addLog(
+ request('/redirect')
+ ..response = ResponseDetails(
+ timestamp: DateTime.now(),
+ statusCode: 302,
+ headers: {
+ 'content-type': ['application/json'],
+ 'Location': [
+ 'https://user:password-secret@next.local/path?token=query-secret',
+ ],
+ },
+ ),
+ );
+ final har = jsonDecode(manager.exportHar());
+ final response = har['log']['entries'].single['response'];
+ expect(response['content']['mimeType'], 'application/json');
+ expect(response['redirectURL'], isNot(contains('password-secret')));
+ expect(response['redirectURL'], isNot(contains('query-secret')));
+ });
+
+ test('mutating a stored record cannot bypass masking on update', () {
+ final raw = request('/data');
+ manager.addLog(raw);
+ final stored = manager.getLog(raw.id)!;
+ stored.response = ResponseDetails(
+ timestamp: DateTime.now(),
+ headers: {'Set-Cookie': 'cookie-secret'},
+ );
+ manager.updateLog(stored);
+ expect(
+ manager.getLog(raw.id)!.response!.headers['Set-Cookie'],
+ '[REDACTED]',
+ );
+ });
+
+ test('cURL drops stale framing headers and unavailable bodies', () {
+ final raw = request('/data', method: 'POST')
+ ..request = RequestDetails(
+ headers: {'Content-Length': '99999', 'Host': 'old.local'},
+ body: '',
+ );
+ final command = EasyDebugExport.curl(raw);
+ expect(command, isNot(contains('Content-Length')));
+ expect(command, isNot(contains('old.local')));
+ expect(command, isNot(contains('--data-raw')));
+ expect(command, contains('Body omitted'));
+ });
+
+ test(
+ 'general severity and search combine; explicit levels take precedence',
+ () {
+ EasyDebug.log('an error counter at zero', level: 'INFO');
+ EasyDebug.log('offline error');
+ EasyDebug.log('retry warning', level: 'WARN');
+ expect(
+ manager
+ .queryGeneralLogs(
+ const GeneralLogQuery(severity: GeneralLogSeverity.error),
+ )
+ .length,
+ 1,
+ );
+ expect(
+ manager
+ .queryGeneralLogs(
+ const GeneralLogQuery(
+ search: 'RETRY',
+ severity: GeneralLogSeverity.warning,
+ ),
+ )
+ .length,
+ 1,
+ );
+ final records = jsonDecode(
+ manager.exportGeneralJson(
+ query: const GeneralLogQuery(severity: GeneralLogSeverity.info),
+ ),
+ );
+ expect(records.single['level'], 'INFO');
+ expect(jsonDecode(manager.exportNetworkJson()), isEmpty);
+ },
+ );
+
+ test(
+ 'pause retains prior logs, ignores new captures, and allows existing updates',
+ () {
+ final first = request('/before');
+ manager.addLog(first);
+ EasyDebug.log('before');
+ manager.pauseRecording();
+ manager.addLog(request('/during'));
+ EasyDebug.log('during');
+ manager.updateLog(manager.getLog(first.id)!.copyWith(durationMs: 999));
+ expect(manager.logsNotifier.value.length, 1);
+ expect(manager.logsNotifier.value.single.durationMs, 999);
+ expect(manager.generalLogsNotifier.value.single.message, 'before');
+ manager.resumeRecording();
+ manager.addLog(request('/after'));
+ EasyDebug.log('after');
+ expect(manager.logsNotifier.value.length, 2);
+ expect(manager.generalLogsNotifier.value.length, 2);
+ manager.shutdown();
+ expect(manager.isPaused, isFalse);
+ expect(manager.isRecording, isFalse);
+ },
+ );
+}
diff --git a/test/regression_test.dart b/test/regression_test.dart
new file mode 100644
index 0000000..9c6a5d5
--- /dev/null
+++ b/test/regression_test.dart
@@ -0,0 +1,71 @@
+import 'dart:async';
+
+import 'package:easy_debug/easy_debug.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+
+class TestClient extends http.BaseClient {
+ TestClient(this.handler);
+ final Future Function(http.BaseRequest) handler;
+
+ @override
+ Future send(http.BaseRequest request) =>
+ handler(request);
+}
+
+void main() {
+ final manager = EasyDebugManager();
+ final originalDebugPrint = debugPrint;
+ setUp(() {
+ manager.shutdown();
+ debugPrint = originalDebugPrint;
+ manager.updateConfig(const EasyDebugConfig());
+ manager.clearLogs();
+ });
+ tearDown(() {
+ manager.shutdown();
+ debugPrint = originalDebugPrint;
+ manager.clearLogs();
+ });
+
+ test('general logs stay bounded during sustained output', () {
+ for (var i = 0; i < 1000; i++) {
+ manager.addGeneralLog('entry $i');
+ }
+ expect(manager.generalLogsNotifier.value.length, lessThanOrEqualTo(200));
+ expect(manager.generalLogsNotifier.value.last.message, 'entry 999');
+ });
+
+ test('initializing twice captures debugPrint once', () async {
+ await manager.init(environments: []);
+ await manager.init(environments: []);
+ debugPrint('one message');
+ expect(manager.generalLogsNotifier.value.length, 1);
+ });
+
+ test('http stream error survives the subsequent done event', () async {
+ final source = StreamController>();
+ final client = EasyDebugHttpClient(
+ TestClient((_) async {
+ return http.StreamedResponse(source.stream, 200);
+ }),
+ );
+ final response = await client.send(
+ http.Request('GET', Uri.https('test.local', '/stream')),
+ );
+ final finished = Completer();
+ response.stream.listen(
+ (_) {},
+ onError: (Object _) {},
+ onDone: finished.complete,
+ );
+ source.add([65]);
+ source.addError(StateError('stream failed'));
+ await source.close();
+ await finished.future;
+ expect(manager.logsNotifier.value.single.error, isNotNull);
+ expect(manager.logsNotifier.value.single.isError, isTrue);
+ client.close();
+ });
+}
diff --git a/test/widget_test.dart b/test/widget_test.dart
new file mode 100644
index 0000000..8daba53
--- /dev/null
+++ b/test/widget_test.dart
@@ -0,0 +1,400 @@
+import 'dart:io';
+import 'dart:async';
+import 'dart:convert';
+import 'dart:ui' as ui;
+
+import 'package:easy_debug/easy_debug.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ final manager = EasyDebugManager();
+ final captureDir = Platform.environment['EASY_DEBUG_CAPTURE_DIR'];
+ setUpAll(() async {
+ final sdk = Platform.environment['EASY_DEBUG_FLUTTER_ROOT'];
+ if (captureDir != null && sdk != null) {
+ final font = FontLoader('Roboto');
+ font.addFont(
+ File(
+ '$sdk/bin/cache/artifacts/material_fonts/Roboto-Regular.ttf',
+ ).readAsBytes().then(ByteData.sublistView),
+ );
+ font.addFont(
+ File(
+ '$sdk/bin/cache/artifacts/material_fonts/Roboto-Bold.ttf',
+ ).readAsBytes().then(ByteData.sublistView),
+ );
+ await font.load();
+ final mono = FontLoader('monospace');
+ mono.addFont(
+ File(
+ Platform.environment['EASY_DEBUG_MONOSPACE_FONT'] ??
+ '$sdk/bin/cache/artifacts/material_fonts/Roboto-Regular.ttf',
+ ).readAsBytes().then(ByteData.sublistView),
+ );
+ await mono.load();
+ final icons = FontLoader('MaterialIcons');
+ icons.addFont(
+ File(
+ '$sdk/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf',
+ ).readAsBytes().then(ByteData.sublistView),
+ );
+ await icons.load();
+ }
+ });
+ setUp(() async {
+ SharedPreferences.setMockInitialValues({});
+ manager.shutdown();
+ manager.updateConfig(const EasyDebugConfig());
+ await Future.delayed(Duration.zero);
+ });
+ tearDown(manager.shutdown);
+
+ Future capture(WidgetTester tester, String name) async {
+ if (captureDir == null) return;
+ final boundary = tester.renderObject(
+ find.byKey(const ValueKey('easy_debug_panel')),
+ );
+ boundary.markNeedsPaint();
+ await tester.pump();
+ await tester.runAsync(() async {
+ final image = await boundary.toImage(pixelRatio: 2);
+ final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
+ await Directory(captureDir).create(recursive: true);
+ await File(
+ '$captureDir/$name.png',
+ ).writeAsBytes(bytes!.buffer.asUint8List());
+ image.dispose();
+ });
+ }
+
+ Future showApp(
+ WidgetTester tester, {
+ Size size = const Size(390, 844),
+ Brightness brightness = Brightness.light,
+ }) async {
+ tester.view.physicalSize = size;
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(useMaterial3: true, brightness: brightness),
+ builder: (_, child) => EasyDebugWidget(child: child!),
+ home: const Scaffold(body: Center(child: Text('Host application'))),
+ ),
+ );
+ await tester.tap(find.byTooltip('Open Easy Debug'));
+ await tester.pumpAndSettle();
+ addTearDown(() async {
+ await tester.pumpWidget(const SizedBox.shrink());
+ manager.clearLogs();
+ await tester.pump();
+ });
+ }
+
+ Future copiedText(WidgetTester tester, String tooltip) async {
+ final copied = Completer();
+ tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
+ SystemChannels.platform,
+ (call) async {
+ if (call.method == 'Clipboard.setData') {
+ copied.complete((call.arguments as Map)['text'] as String);
+ }
+ return null;
+ },
+ );
+ addTearDown(
+ () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
+ SystemChannels.platform,
+ null,
+ ),
+ );
+ await tester.tap(find.byTooltip(tooltip));
+ await tester.pump();
+ final watch = Stopwatch()..start();
+ while (!copied.isCompleted && watch.elapsed < const Duration(seconds: 5)) {
+ // Drive fake Flutter frames while allowing the native compute isolate to finish.
+ await tester.runAsync(
+ () => Future.delayed(const Duration(milliseconds: 10)),
+ );
+ await tester.pump();
+ }
+ expect(
+ copied.isCompleted,
+ isTrue,
+ reason: 'Clipboard export did not finish',
+ );
+ return copied.future;
+ }
+
+ NetworkLog sample(String path, {int? code = 200}) =>
+ NetworkLog(
+ timestamp: DateTime(2026, 11, 3, 14, 32, 8),
+ method: 'GET',
+ url: 'https://api.example.com$path',
+ )
+ ..request = RequestDetails(headers: {'Accept': 'application/json'})
+ ..response = code == null
+ ? null
+ : ResponseDetails(
+ statusCode: code,
+ timestamp: DateTime.now(),
+ body: {'status': 'ready'},
+ )
+ ..durationMs = code == null ? null : 124
+ ..isComplete = code != null;
+
+ for (final size in [
+ const Size(320, 568),
+ const Size(390, 844),
+ const Size(1100, 800),
+ ]) {
+ testWidgets('console opens, searches and filters at $size', (tester) async {
+ manager.addLog(sample('/v1/orders/42'));
+ manager.addLog(sample('/v1/orders/missing', code: 404));
+ manager.addLog(sample('/v1/events', code: null));
+ await showApp(tester, size: size);
+ expect(find.text('Easy Debug'), findsOneWidget);
+ if (size.width == 390) await capture(tester, 'network_light');
+ await tester.tap(find.text('Error'));
+ await tester.pumpAndSettle();
+ expect(find.text('/v1/orders/missing'), findsOneWidget);
+ expect(find.text('/v1/orders/42'), findsNothing);
+ await tester.tap(find.text('All'));
+ await tester.enterText(find.byType(TextField), 'events');
+ await tester.pump(const Duration(milliseconds: 200));
+ await tester.pumpAndSettle();
+ expect(find.text('/v1/events'), findsOneWidget);
+ expect(find.text('/v1/orders/missing'), findsNothing);
+ expect(tester.takeException(), isNull);
+ });
+ }
+
+ testWidgets(
+ 'details update after opening and retain error when response exists',
+ (tester) async {
+ final pending = sample('/v1/orders/42', code: null);
+ manager.addLog(pending);
+ await showApp(tester);
+ await tester.tap(find.text('/v1/orders/42'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Response'));
+ await tester.pumpAndSettle();
+ expect(find.text('Waiting for response…'), findsOneWidget);
+ manager.updateLog(
+ manager
+ .getLog(pending.id)!
+ .copyWith(
+ response: ResponseDetails(
+ statusCode: 500,
+ body: '{"message":"service unavailable"}',
+ timestamp: DateTime.now(),
+ ),
+ error: ErrorDetails(errorMessage: 'Connection interrupted'),
+ isComplete: true,
+ durationMs: 846,
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(find.text('Waiting for response…'), findsNothing);
+ expect(find.textContaining('Connection interrupted'), findsOneWidget);
+ expect(find.textContaining('service unavailable'), findsOneWidget);
+ expect(tester.getTopLeft(find.text('Request details')).dy, lessThan(80));
+ await capture(tester, 'request_details');
+ await tester.tap(find.byTooltip('Back to requests'));
+ await tester.pumpAndSettle();
+ expect(find.text('Easy Debug'), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ },
+ );
+
+ testWidgets('dark mode logs, settings, clear and close work', (tester) async {
+ await manager.init(
+ environments: const [
+ AppEnvironment(
+ name: 'Production',
+ baseUrl: 'https://api.example.com',
+ isDefault: true,
+ ),
+ AppEnvironment(name: 'Development', baseUrl: 'https://dev.example.com'),
+ ],
+ );
+ EasyDebug.log('Session started. Ready to inspect requests.', level: 'INFO');
+ EasyDebug.log('Network retry scheduled in 2 seconds.', level: 'WARN');
+ EasyDebug.log('Could not load the product catalogue.', level: 'ERROR');
+ await showApp(tester, brightness: Brightness.dark);
+ await tester.tap(find.text('Logs'));
+ await tester.pumpAndSettle();
+ expect(find.textContaining('Session started'), findsOneWidget);
+ await capture(tester, 'logs_dark');
+ await tester.tap(find.text('Settings'));
+ await tester.pumpAndSettle();
+ expect(find.text('Production'), findsWidgets);
+ await capture(tester, 'settings_dark');
+ await tester.tap(find.byTooltip('Clear logs'));
+ await tester.pumpAndSettle();
+ expect(manager.generalLogsNotifier.value, isEmpty);
+ await tester.tap(find.byTooltip('Close console'));
+ await tester.pumpAndSettle();
+ expect(find.text('Host application'), findsOneWidget);
+ manager.shutdown();
+ await tester.pump();
+ });
+
+ testWidgets('disabled config hides overlay and stops collection', (
+ tester,
+ ) async {
+ await showApp(tester);
+ await capture(tester, 'empty_state');
+ manager.updateConfig(const EasyDebugConfig(enabled: false));
+ await tester.pumpAndSettle();
+ expect(find.text('Easy Debug'), findsNothing);
+ expect(find.byTooltip('Open Easy Debug'), findsNothing);
+ expect(find.text('Host application'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'console handles keyboard insets and enlarged text on a narrow screen',
+ (tester) async {
+ await showApp(tester, size: const Size(320, 568));
+ tester.platformDispatcher.textScaleFactorTestValue = 1.5;
+ tester.view.viewInsets = const FakeViewPadding(bottom: 220);
+ addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
+ addTearDown(tester.view.resetViewInsets);
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ },
+ );
+
+ testWidgets(
+ 'tooltips and long press have an overlay in MaterialApp builder',
+ (tester) async {
+ await showApp(tester);
+ await tester.longPress(find.byTooltip('Clear logs'));
+ await tester.pumpAndSettle();
+ expect(tester.takeException(), isNull);
+ },
+ );
+
+ testWidgets(
+ 'floating button remains reachable after dragging beyond screen',
+ (tester) async {
+ await showApp(tester);
+ await tester.tap(find.byTooltip('Close console'));
+ await tester.pumpAndSettle();
+ await tester.drag(
+ find.byTooltip('Open Easy Debug'),
+ const Offset(3000, 3000),
+ );
+ await tester.pumpAndSettle();
+ final rect = tester.getRect(find.byTooltip('Open Easy Debug'));
+ expect(rect.right, lessThanOrEqualTo(390));
+ expect(rect.bottom, lessThanOrEqualTo(844));
+ await tester.tap(find.byTooltip('Open Easy Debug'));
+ await tester.pumpAndSettle();
+ expect(find.text('Easy Debug'), findsOneWidget);
+ },
+ );
+
+ testWidgets('pause and resume keep prior records visible', (tester) async {
+ manager.addLog(sample('/before'));
+ await showApp(tester);
+ await tester.tap(find.byTooltip('Pause capture'));
+ await tester.pumpAndSettle();
+ manager.addLog(sample('/ignored'));
+ EasyDebug.log('ignored');
+ expect(manager.logsNotifier.value.length, 1);
+ expect(find.text('Capture paused'), findsOneWidget);
+ expect(find.byTooltip('Resume capture'), findsOneWidget);
+ await capture(tester, 'capture_paused');
+ await tester.tap(find.byTooltip('Resume capture'));
+ manager.addLog(sample('/after'));
+ await tester.pumpAndSettle();
+ expect(manager.logsNotifier.value.length, 2);
+ expect(find.text('/after'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'advanced filters survive details and HAR exports only the visible selection',
+ (tester) async {
+ manager.addLog(sample('/fast'));
+ manager.addLog(
+ sample('/post').copyWith(method: 'POST', durationMs: 1400),
+ );
+ manager.addLog(
+ sample('/other').copyWith(
+ method: 'POST',
+ url: 'https://other.example.com/other',
+ durationMs: 2000,
+ ),
+ );
+ await showApp(tester);
+ await tester.tap(find.byTooltip('Advanced filters'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byType(DropdownMenu).first);
+ await tester.pumpAndSettle();
+ await tester.tap(find.widgetWithText(MenuItemButton, 'POST'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byType(DropdownMenu).last);
+ await tester.pumpAndSettle();
+ await tester.tap(find.widgetWithText(MenuItemButton, 'api.example.com'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Slow requests · 1 s+'));
+ await tester.pumpAndSettle();
+ expect(find.text('1 / 3 requests'), findsOneWidget);
+ expect(find.text('/fast'), findsNothing);
+ expect(find.text('/other'), findsNothing);
+ await capture(tester, 'advanced_filters');
+ final har = jsonDecode(await copiedText(tester, 'Copy filtered HAR'));
+ expect(har['log']['entries'].length, 1);
+ expect(har['log']['entries'].single['request']['method'], 'POST');
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('/post'));
+ await tester.pumpAndSettle();
+ expect(find.byTooltip('Copy as cURL'), findsOneWidget);
+ final curl = await copiedText(tester, 'Copy as cURL');
+ expect(curl, contains("--request 'POST'"));
+ expect(curl, contains('https://api.example.com/post'));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byTooltip('Back to requests'));
+ await tester.pumpAndSettle();
+ expect(find.text('1 / 3 requests'), findsOneWidget);
+ await tester.tap(find.byTooltip('Reset filters'));
+ await tester.pumpAndSettle();
+ expect(find.text('3 / 3 requests'), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ },
+ );
+
+ testWidgets(
+ 'general logs combine severity and text search with JSON export',
+ (tester) async {
+ EasyDebug.log('Catalogue loaded', level: 'INFO');
+ EasyDebug.log('Could not load catalogue', level: 'ERROR');
+ await showApp(tester, brightness: Brightness.dark);
+ await tester.tap(find.text('Logs'));
+ await tester.pumpAndSettle();
+ await tester.drag(find.byType(ListView).first, const Offset(-350, 0));
+ await tester.pumpAndSettle();
+ await tester.tap(find.text('Error'));
+ await tester.enterText(find.byType(TextField), 'catalogue');
+ await tester.pump(const Duration(milliseconds: 200));
+ await tester.pumpAndSettle();
+ expect(find.text('Catalogue loaded'), findsNothing);
+ expect(find.text('Could not load catalogue'), findsOneWidget);
+ final records = jsonDecode(
+ await copiedText(tester, 'Copy filtered logs as JSON'),
+ );
+ expect(records.length, 1);
+ expect(records.single['level'], 'ERROR');
+ await tester.pumpAndSettle();
+ expect(find.byTooltip('Copied'), findsOneWidget);
+ expect(tester.takeException(), isNull);
+ },
+ );
+}