diff --git a/.editorconfig b/.editorconfig
index 1265605..2580db7 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -22,3 +22,9 @@ indent_size = 2
[*.md]
trim_trailing_whitespace = false
+
+[packages/flutwire/test/fixtures/**]
+# Bytes that a test compares exactly, with no final newline to add or trailing
+# space to trim.
+insert_final_newline = false
+trim_trailing_whitespace = false
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..7d7c5f1
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+# The fixtures are bytes that tests compare exactly, so git never converts them.
+packages/flutwire/test/fixtures/** -text
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 057544e..f1d6b74 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -95,6 +95,34 @@ jobs:
echo "::endgroup::"
done
+ # A version number is a promise about the public API, and nothing else checks
+ # it. Each package is compared with its latest release on pub.dev, and the job
+ # fails when the API breaks and the pubspec has not moved to a version that
+ # allows it: from 1.0 a removed or changed member needs the next major.
+ # STABILITY.md says what each kind of change means.
+ api:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+ - run: flutter pub get
+ - run: dart pub global activate dart_apitool 0.23.2
+ - name: Compare each package with its latest release
+ run: |
+ export PATH="$PATH:$HOME/.pub-cache/bin"
+ status=0
+ for pkg in flutwire flutwire_dio flutwire_http flutwire_cloud_functions; do
+ latest=$(curl -fsS "https://pub.dev/api/packages/$pkg" | jq -r .latest.version)
+ echo "::group::$pkg against $latest"
+ dart-apitool diff --old "pub://$pkg/$latest" --new "packages/$pkg" \
+ --version-check-mode onlyBreakingChanges || status=1
+ echo "::endgroup::"
+ done
+ exit $status
+
# The plugin has native halves that `flutter analyze` never reads. Building
# the example app is what compiles them, so a Kotlin or Swift error fails
# here and not on a stranger's machine.
@@ -114,6 +142,20 @@ jobs:
- run: flutter build apk --debug
working-directory: packages/flutwire/example
+ # The web has no dart:io, so it is where the adapters do the recording and
+ # where the example is compiled by a different compiler than anywhere else.
+ web:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+ - run: flutter pub get
+ - run: flutter build web
+ working-directory: packages/flutwire/example
+
# The Swift halves can only be compiled on a Mac.
apple:
runs-on: macos-latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 247b2c7..4e938e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,37 @@ board can be cross-checked against what actually shipped.
## [Unreleased]
+## [1.0.0] - 2026-10-24
+
+### Added
+
+- A stability policy, `STABILITY.md`. It says what is public, what a version bump means for the API, the history on disk and the JSON export, how long a deprecated member stays, which versions get fixes, and which platforms and toolchains are supported. It also holds three promises about behaviour: recording does not change traffic unless `interception` is turned on, a release build records nothing unless it is asked to, and the default redaction masks credentials (M25)
+- The history on disk records its layout in a file called `layout`. A folder with none is a new one, or one that 0.1.0 or 0.2.0 wrote, and is read as layout 1. A folder with a layout this release does not know is left exactly as it is while the history is kept in memory, where an older release would have deleted every summary it could not read (M40)
+- A second tour in the README, with a 74 second video, and four more screenshots on the pub.dev page of `flutwire`, for what 0.2.0 added: two calls compared, the statistics, the dock, and the mocks and breakpoints with the marks they leave on the list. The page now shows ten screenshots, with the light list and the XML body making room (M37)
+
+### Changed
+
+- Every export of a package names what it makes public. `parseFormEncoded` and `prettyXml` were exported with the body model by accident and no longer are: `FlutwireBody.formFields` and `FlutwireBody.prettyText` are how to reach what they do (M25)
+- `FlutwireCallRecorder.observeRequest` is removed. Nothing called it, and a client that has a request body as a stream reports it with `requestBody` (M25)
+- `FlutwireStore`, `MemoryFlutwireStore` and `FlutwirePreferences` are `base` classes, so none can be implemented outside the package, and `FlutwireClient` in `flutwire_http` is `final`. None was meant to be implemented or extended by an app, and each can now gain a member without breaking one. `FlutwireBodyDecoder` stays an interface to implement. The API comparison does not see a change of class modifier, so it is listed here (M25)
+- `FlutwireExportFormat.render` writes this package's version as the creator of a HAR where it wrote `0` (M25)
+- On a screen narrower than 600 logical pixels the docked inspector always sits under the app, and its menu does not offer the right side there, because beside the app it left neither of them a usable width (M39)
+
+### Fixed
+
+- A Protobuf enum with a negative value showed as a number instead of its name, because a descriptor writes such a value as ten bytes and the reader dropped it (M38)
+- A Protobuf float showed the 17 digits of the double it widens to, `0.10000000149011612` for `0.1`. It shows the shortest decimal that reads back as the same float (M38)
+- The four numbers above the list ran into each other in a narrow panel and at a large text size. They stand in two rows of two when four do not fit, and the title of a selection shrinks to the room the buttons leave instead of being cut off (M39)
+
+### Internal
+
+- CI compares the public API of each package with its latest release on pub.dev, and fails when the API breaks and the pubspec has not moved to a version that allows it (M41)
+- CI builds the example for the web, which no job did before (M39)
+- The Protobuf decoder is tested against a descriptor set and messages that `protoc` and the code it generates wrote. Its other tests build their input by hand (M38)
+- A history written by the 0.2.0 code, with a summary in the 0.1.0 shape, is read back in a test (M40)
+- `integration_test/features_smoke_test.dart` records a call through `dart:io` and opens the comparison, the statistics and the dock on the platform it is started on. It was run on macOS and on an iOS simulator, and the example was run by hand on the web (M39)
+- The example adds no button of its own where Flutwire floats one, so on the web the two no longer overlap (M39)
+
## [0.2.0] - 2026-10-24
### Added
@@ -66,6 +97,7 @@ board can be cross-checked against what actually shipped.
- The screens of the inspector work on an injected session instead of reading a global, which lets them be tested against a fake (M33)
- Analysis rules for open subscriptions and sinks, dropped futures, catch clauses that name no type, positional booleans and dynamic calls (M33)
-[Unreleased]: https://github.com/NaCode-Studios/Flutwire/compare/v0.2.0...HEAD
+[Unreleased]: https://github.com/NaCode-Studios/Flutwire/compare/v1.0.0...HEAD
+[1.0.0]: https://github.com/NaCode-Studios/Flutwire/compare/v0.2.0...v1.0.0
[0.2.0]: https://github.com/NaCode-Studios/Flutwire/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/NaCode-Studios/Flutwire/releases/tag/v0.1.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5bf5d2f..a94af18 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -42,9 +42,16 @@ flutter analyze --fatal-infos
(cd packages/flutwire_cloud_functions && flutter test)
```
-The Android and Apple halves are compiled by building the example app: `flutter build apk --debug`, `flutter build ios --simulator --no-codesign` and `flutter build macos --debug`, each from `packages/flutwire/example`.
+The Android and Apple halves are compiled by building the example app: `flutter build apk --debug`, `flutter build ios --simulator --no-codesign` and `flutter build macos --debug`, each from `packages/flutwire/example`. The web has no native half, and `flutter build web` from the same folder is what compiles the example for it.
-How the JSON tree behaves on a big response is measured on a device, in profile mode, by `integration_test/json_tree_performance_test.dart` in the example app. Its first lines say how to run it. CI does not run it, because it needs a device.
+The public API is checked against the latest release on pub.dev, and a breaking change needs a new major version in the pubspec. To see what a change does to it:
+
+```bash
+dart pub global activate dart_apitool 0.23.2
+dart-apitool diff --old pub://flutwire/ --new packages/flutwire --version-check-mode onlyBreakingChanges
+```
+
+How the JSON tree behaves on a big response is measured on a device, in profile mode, by `integration_test/json_tree_performance_test.dart` in the example app, and `integration_test/features_smoke_test.dart` runs the comparison, the statistics and the dock on the platform it is started on. Their first lines say how to run them. CI runs neither, because they need a device.
## Commits and branches
diff --git a/README.md b/README.md
index 4998a48..8cc7e81 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,16 @@ MaterialApp(builder: Flutwire.builder); // a button where there is no
+The inspector also sets two calls side by side, adds up the numbers of a history and sits in a panel beside the app. With interception turned on, a rule answers a request with a mock or holds it at a breakpoint.
+
+
+
+
+
+
+ Watch it work , 74 seconds on an Android emulator.
+
+
This is what the notification lists, and what a call becomes when it is shared as a command:
```text
@@ -66,7 +76,7 @@ Flutwire reads traffic and changes it only through the mocks and breakpoints the
In a release build `Flutwire.init` does nothing: no call is recorded, no file is written and no notification is posted. The plugin's native code still ships in the app, like any other plugin's, and stays idle until Flutwire starts: on iOS and macOS it leaves the app's notification delegate alone, and on Android its manifest adds the notification permission to the merged one.
-> **Status — `0.2`, alpha.** Minor versions may still break the API until `1.0`. The plan is on [the project board](https://github.com/orgs/NaCode-Studios/projects/13).
+> **Status — `1.0`, stable.** The public API follows [Semantic Versioning](STABILITY.md): a breaking change needs a new major version, and a deprecated member stays for at least two minor releases. The plan is on [the project board](https://github.com/orgs/NaCode-Studios/projects/13).
## Why Flutwire
@@ -121,11 +131,13 @@ Flutwire
## Roadmap
+**Shipped (`1.0.0`).** A review of the public API, so that every export names what it makes public and the types that were never meant to be implemented are closed to it, a written [stability policy](STABILITY.md) with its deprecation window, a layout number on the history kept on disk, and a Protobuf decoder checked against what `protoc` writes. [`CHANGELOG.md`](CHANGELOG.md) has the detail.
+
**Shipped (`0.2.0`).** Editing a call and sending it again, the messages of a WebSocket and of an event stream, comparing two calls, statistics and a timeline, a dock beside the app, mocks and breakpoints, calls made in other isolates, Protobuf and MessagePack bodies, and a note above the list when calls may have gone unseen. [`CHANGELOG.md`](CHANGELOG.md) has the detail.
**Shipped (`0.1.0`).** Capture, the history on disk, the notification on Android, iOS and macOS, the inspector with the body viewer, export, the adapters and the example app.
-**Next, `1.0.0`: stabilisation.** A review of the public API so that every public member is documented or made private, and a written stability policy with its deprecation window. gRPC capture comes after `1.0`. The ideas that were turned down, with the reason, are on the board too.
+**Next.** gRPC capture. gRPC in Dart opens its own sockets, so the `HttpClient` wrapper cannot see it, and it needs an interceptor for the `grpc` package. It has no date yet. The ideas that were turned down, with the reason, are on the board too.
The plan lives on [the project board](https://github.com/orgs/NaCode-Studios/projects/13) and is mirrored as [milestones](https://github.com/NaCode-Studios/Flutwire/milestones).
diff --git a/STABILITY.md b/STABILITY.md
new file mode 100644
index 0000000..25893dd
--- /dev/null
+++ b/STABILITY.md
@@ -0,0 +1,65 @@
+# Stability policy
+
+Flutwire follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This document says what that means in practice: what is covered, what is not, and how long a deprecated API survives. It complements [the project board](https://github.com/orgs/NaCode-Studios/projects/13), where the project is going, and [`CHANGELOG.md`](CHANGELOG.md), which says what has shipped.
+
+The four packages, `flutwire`, `flutwire_dio`, `flutwire_http` and `flutwire_cloud_functions`, are released together under one version number, and each adapter asks for the major version of `flutwire` that it was released with.
+
+## What is public
+
+The public API is what a package exports from its main library: `package:flutwire/flutwire.dart`, `package:flutwire_dio/flutwire_dio.dart`, `package:flutwire_http/flutwire_http.dart` and `package:flutwire_cloud_functions/flutwire_cloud_functions.dart`. Every export names what it makes public, so nothing is public only because it shares a file with something that is.
+
+Anything under a `src/` folder that those libraries do not export may change in any release, and so may a member annotated `@internal`, `@visibleForTesting` or `@protected`. The `@protected` ones are hooks for the subclasses inside the package, not for yours. Three more things are not covered, because they are made to be looked at and not called:
+
+- The look, the wording and the languages of the inspector, and what the notification says.
+- The example app, and the native code behind the notification.
+- The exact text of the plain text and cURL exports. A cURL command that stops repeating the call it describes is a bug, but how it is quoted may change.
+
+CI compares each package with its latest release on pub.dev and fails when the public API breaks and the version in the pubspec does not allow it. The comparison does not see a change of class modifier, such as a class that stops being extendable, or a change in behaviour, so those are held to this document and to review.
+
+### How the types may be used
+
+`FlutwireBodyDecoder` is the one interface meant to be implemented: write one to show a format the inspector does not know. It will not gain a member before the next major.
+
+Everything else is for reading and constructing, not for implementing or extending. The model classes and the configuration are `final`, and `FlutwireStore` and `FlutwirePreferences` are `base`, so a minor release can add a member to any of them without breaking a program that compiled against the one before. A new value may be added to an enum in a minor release, so a `switch` over one wants a `default` case.
+
+## What a version bump means
+
+| Change | Bump |
+| --- | --- |
+| A public member is removed or renamed, a parameter or a type changes incompatibly, a class stops being usable the way it was, or a promise on this page stops holding | major |
+| A public member is added, an option is added with a default that keeps today's behaviour, a new kind of call is recorded | minor |
+| A fix that brings behaviour back to what was documented | patch |
+
+Some promises are about behaviour and not about names. Recording does not change traffic: the calls an app makes and the answers it gets are the same with Flutwire and without it. Mocks and breakpoints are the one exception, and they stay off until `FlutwireConfig.interception` is turned on, which no release will do by default. The only requests Flutwire makes itself are the ones a person edits and sends again. A release build records nothing unless `allowInRelease` is set, and no release will change that default. With the default redaction, the values of `authorization`, `proxy-authorization`, `cookie` and `set-cookie` are masked when a call is recorded. A release may add a field to `FlutwireRedaction.defaultHeaders`, and none will take one out before the next major.
+
+### What is written to disk and to a file
+
+The history on disk has a layout, a number kept in a file called `layout` in its folder, and it is `1`. A folder with no such file is a new one, or one that 0.1.0 or 0.2.0 wrote, which has layout `1` as well.
+
+A release reads every layout up to its own. A minor or a patch does not change the layout: a field may be added to a summary, because a reader ignores what it does not know, and a field that a reader needs is not removed. A new layout comes only with a major release, which reads the layout of the one before it. A release that finds a layout newer than its own leaves the folder exactly as it is and keeps the history in memory, so going back to an older release never costs the history of a newer one.
+
+The Flutwire JSON export carries `"flutwire": 1` at its top. A field may be added to it in a minor release, and none is removed or renamed before the next major, which is also the only release that changes that number. The HAR export follows HAR 1.2.
+
+## Deprecation
+
+A deprecated API survives at least two minor releases before removal, carries a deprecation notice naming its replacement, and is listed under `Deprecated` in [`CHANGELOG.md`](CHANGELOG.md) in the release that deprecates it. It is removed only in a major release.
+
+## Supported versions
+
+The latest minor release of the latest major receives fixes. An older minor does not: the answer to a bug in `1.2` is `1.3`.
+
+## Platform and toolchain support
+
+The packages need Dart 3.9 and Flutter 3.35, as their pubspecs say. CI runs the stable channel and, separately, the lowest versions of every dependency the constraints allow.
+
+| Platform | What it gets |
+| --- | --- |
+| Android 7 (API 24) and later | The notification and the inspector |
+| iOS 15 and later | The notification and the inspector |
+| macOS 11 and later | The notification and the inspector |
+| Windows and Linux | The inspector, opened by a floating button and a keyboard shortcut |
+| Web | The same, with no `dart:io`, so calls are recorded by an adapter, and sending a call again, mocks and breakpoints are not offered |
+
+The example app is built for Android, iOS, macOS and the web on every pull request. Windows and Linux need no native code, and their builds are not run.
+
+Raising the minimum version of Dart, Flutter or one of those platforms is a minor release, and it is listed under `Changed` in [`CHANGELOG.md`](CHANGELOG.md).
diff --git a/docs/media/flutwire-beyond-dark.png b/docs/media/flutwire-beyond-dark.png
new file mode 100644
index 0000000..7841f5e
Binary files /dev/null and b/docs/media/flutwire-beyond-dark.png differ
diff --git a/docs/media/flutwire-beyond-light.png b/docs/media/flutwire-beyond-light.png
new file mode 100644
index 0000000..9864cff
Binary files /dev/null and b/docs/media/flutwire-beyond-light.png differ
diff --git a/docs/media/flutwire-beyond.mp4 b/docs/media/flutwire-beyond.mp4
new file mode 100644
index 0000000..75e4773
Binary files /dev/null and b/docs/media/flutwire-beyond.mp4 differ
diff --git a/packages/flutwire/CHANGELOG.md b/packages/flutwire/CHANGELOG.md
index 3fa3c33..bb92a72 100644
--- a/packages/flutwire/CHANGELOG.md
+++ b/packages/flutwire/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 1.0.0
+
+The first stable release. The public API follows [Semantic Versioning](https://github.com/NaCode-Studios/Flutwire/blob/main/STABILITY.md), every export names what it makes public, and the history kept on disk records its layout, so that a release that does not know a layout leaves it alone. A Protobuf enum with a negative value shows by name, and a float shows its shortest digits. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#100---2026-10-24) lists what it contains, and what was removed or closed to implementing.
+
## 0.2.0
Adds editing a call and sending it again, the messages of a WebSocket and of an event stream, comparing two calls, statistics and a timeline, a dock beside the app, mocks and breakpoints, recording of the calls made in other isolates, and Protobuf and MessagePack bodies. On iOS and macOS the plugin no longer takes over the app's notification delegate in a build that never starts Flutwire. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#020---2026-10-24) lists what it contains.
diff --git a/packages/flutwire/README.md b/packages/flutwire/README.md
index 81d4fda..91e1892 100644
--- a/packages/flutwire/README.md
+++ b/packages/flutwire/README.md
@@ -35,6 +35,16 @@ That is the whole integration. From then on every call that goes through `dart:i
+The inspector also sets two calls side by side, adds up the numbers of a history and sits in a panel beside the app. With interception turned on, a rule answers a request with a mock or holds it at a breakpoint.
+
+
+
+
+
+
+ Watch it work , 74 seconds on an Android emulator.
+
+
## Opening the inspector
On Android, iOS and macOS the notification opens it. On Android 13 and later Flutwire asks for the notification permission the first time there is something to show. If the user declines, `Flutwire.open()` and the "Open Flutwire" shortcut on the app icon still work.
diff --git a/packages/flutwire/darwin/flutwire.podspec b/packages/flutwire/darwin/flutwire.podspec
index 2f8f75a..640bb09 100644
--- a/packages/flutwire/darwin/flutwire.podspec
+++ b/packages/flutwire/darwin/flutwire.podspec
@@ -4,7 +4,7 @@
#
Pod::Spec.new do |s|
s.name = 'flutwire'
- s.version = '0.2.0'
+ s.version = '1.0.0'
s.summary = 'Network inspector for Flutter that lives in the notification shade.'
s.description = <<-DESC
Records the HTTP calls a Flutter app makes and opens them from a notification.
diff --git a/packages/flutwire/example/integration_test/features_smoke_test.dart b/packages/flutwire/example/integration_test/features_smoke_test.dart
new file mode 100644
index 0000000..271849b
--- /dev/null
+++ b/packages/flutwire/example/integration_test/features_smoke_test.dart
@@ -0,0 +1,138 @@
+// Runs what 0.2.0 added on the platform it is started on: a call made through
+// dart:io is recorded, two calls are compared, the statistics add them up and
+// the inspector docks beside the app. It goes through the public API and the
+// real screens against a server on the loopback interface, so it needs a
+// device or a desktop and no network:
+//
+// flutter test integration_test/features_smoke_test.dart -d macos
+// flutter test integration_test/features_smoke_test.dart -d
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutwire/flutwire.dart';
+import 'package:integration_test/integration_test.dart';
+
+void main() {
+ final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
+
+ Future settle(WidgetTester tester, [int frames = 10]) async {
+ for (var i = 0; i < frames; i++) {
+ await tester.pump(const Duration(milliseconds: 100));
+ }
+ }
+
+ testWidgets('records, compares, adds up and docks', (tester) async {
+ // A wide screen, so the inspector has two panes and docks on the right.
+ tester.view.physicalSize =
+ const Size(1400, 900) * tester.view.devicePixelRatio;
+ addTearDown(tester.view.resetPhysicalSize);
+
+ await Flutwire.init(
+ config: const FlutwireConfig(
+ enabled: true,
+ persist: false,
+ shortcut: false,
+ interception: true,
+ locale: Locale('en'),
+ notification: FlutwireNotificationConfig(enabled: false),
+ ),
+ );
+
+ final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
+ addTearDown(() => server.close(force: true));
+ server.listen((request) async {
+ final path = request.uri.path;
+ await Future.delayed(
+ Duration(milliseconds: path == '/orders/2' ? 150 : 10),
+ );
+ final response = request.response;
+ if (path == '/missing') {
+ response
+ ..statusCode = HttpStatus.notFound
+ ..write('no such order');
+ } else {
+ response
+ ..headers.contentType = ContentType.json
+ ..write('{"path":"$path","items":[1,2,3]}');
+ }
+ await response.close();
+ });
+
+ Future get(String path) async {
+ final client = HttpClient();
+ final request = await client.getUrl(
+ Uri.parse('http://127.0.0.1:${server.port}$path'),
+ );
+ final response = await request.close();
+ await response.drain();
+ client.close();
+ }
+
+ await get('/orders/1');
+ await get('/orders/2');
+ await get('/missing');
+
+ await tester.pumpWidget(
+ MaterialApp(
+ navigatorKey: Flutwire.navigatorKey,
+ builder: Flutwire.builder,
+ home: const Scaffold(body: Center(child: Text('the app'))),
+ ),
+ );
+ await settle(tester);
+
+ // The calls went through dart:io and were recorded as they ended.
+ final calls = Flutwire.calls.value;
+ expect(
+ [for (final c in calls) c.uri.path],
+ ['/missing', '/orders/2', '/orders/1'],
+ );
+ expect([for (final c in calls) c.statusCode], [404, 200, 200]);
+ expect(calls[1].response!.body.text, contains('/orders/2'));
+
+ unawaited(Flutwire.open());
+ await settle(tester);
+ expect(find.text('Network activity'), findsOneWidget);
+
+ // Two calls side by side.
+ await tester.longPress(find.text('/orders/2'));
+ await settle(tester, 5);
+ await tester.tap(find.text('/orders/1'));
+ await settle(tester, 5);
+ await tester.tap(find.byTooltip('Compare'));
+ await settle(tester);
+ expect(find.text('Differences only'), findsOneWidget);
+ expect(find.text('/orders/1'), findsWidgets);
+ await tester.pageBack();
+ await settle(tester);
+ await tester.tap(find.byTooltip('Close'));
+ await settle(tester);
+
+ // The numbers of the history, and its timeline.
+ await tester.tap(find.byTooltip('Statistics'));
+ await settle(tester);
+ expect(find.text('Summary'), findsOneWidget);
+ expect(find.text('Error rate'.toUpperCase()), findsOneWidget);
+ await tester.tap(find.text('Timeline'));
+ await settle(tester);
+ // The three calls ran one after the other.
+ expect(find.text('One at a time'), findsOneWidget);
+ await tester.pageBack();
+ await settle(tester);
+
+ // Docked, the inspector sits to the right of the app, which stays.
+ await tester.tap(find.byIcon(Icons.more_vert_rounded));
+ await settle(tester);
+ await tester.tap(find.text('Dock beside the app'));
+ await settle(tester);
+ expect(find.text('the app'), findsOneWidget);
+ expect(
+ tester.getTopLeft(find.text('Network activity')).dx,
+ greaterThan(tester.getTopLeft(find.text('the app')).dx),
+ );
+ expect(tester.takeException(), isNull);
+ });
+}
diff --git a/packages/flutwire/example/lib/main.dart b/packages/flutwire/example/lib/main.dart
index dc4d39b..155b0c8 100644
--- a/packages/flutwire/example/lib/main.dart
+++ b/packages/flutwire/example/lib/main.dart
@@ -5,7 +5,8 @@ import 'dart:isolate';
import 'dart:typed_data';
import 'package:dio/dio.dart';
-import 'package:flutter/foundation.dart' show kIsWeb;
+import 'package:flutter/foundation.dart'
+ show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutwire/flutwire.dart';
import 'package:flutwire_dio/flutwire_dio.dart';
@@ -42,6 +43,15 @@ class ExampleApp extends StatelessWidget {
}
}
+/// Whether the platform has a notification that opens the inspector.
+bool get _hasNotification =>
+ !kIsWeb &&
+ const {
+ TargetPlatform.android,
+ TargetPlatform.iOS,
+ TargetPlatform.macOS,
+ }.contains(defaultTargetPlatform);
+
/// One button of the demo: a label and the call it makes.
class Probe {
const Probe(this.label, this.run);
@@ -404,11 +414,15 @@ class Home extends StatelessWidget {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Flutwire example')),
- floatingActionButton: FloatingActionButton.extended(
- onPressed: () => Flutwire.open(context),
- icon: const Icon(Icons.cable_rounded),
- label: const Text('Open the inspector'),
- ),
+ // Where there is no notification Flutwire floats a button of its own in
+ // this corner, so the example adds none.
+ floatingActionButton: _hasNotification
+ ? FloatingActionButton.extended(
+ onPressed: () => Flutwire.open(context),
+ icon: const Icon(Icons.cable_rounded),
+ label: const Text('Open the inspector'),
+ )
+ : null,
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
children: [
diff --git a/packages/flutwire/lib/flutwire.dart b/packages/flutwire/lib/flutwire.dart
index 0776c9a..0a5f7fb 100644
--- a/packages/flutwire/lib/flutwire.dart
+++ b/packages/flutwire/lib/flutwire.dart
@@ -5,18 +5,43 @@
library;
export 'src/capture/recorder.dart' show FlutwireCallRecorder;
-export 'src/capture/redaction.dart';
-export 'src/config.dart';
+export 'src/capture/redaction.dart' show FlutwireRedaction;
+export 'src/config.dart'
+ show
+ FlutwireBodyDecoder,
+ FlutwireButtonMode,
+ FlutwireConfig,
+ FlutwireIgnore,
+ FlutwireNotificationConfig,
+ FlutwireRetention;
export 'src/coverage.dart' show FlutwireCoverageNote, FlutwireGap;
export 'src/decode/messagepack.dart' show FlutwireMessagePackDecoder;
export 'src/decode/protobuf_decoder.dart' show FlutwireProtobufDecoder;
export 'src/engine.dart' show FlutwireStandDown;
export 'src/export/export.dart' show FlutwireExportFormat, callToJson;
export 'src/flutwire.dart' show Flutwire;
-export 'src/model/body.dart';
-export 'src/model/call.dart';
-export 'src/model/headers.dart';
-export 'src/model/message.dart';
-export 'src/model/multipart.dart';
-export 'src/store/preferences.dart';
-export 'src/store/store.dart';
+export 'src/model/body.dart' show FlutwireBody, FlutwireBodyKind;
+export 'src/model/call.dart'
+ show
+ FlutwireCall,
+ FlutwireCallState,
+ FlutwireCertificate,
+ FlutwireError,
+ FlutwireGraphql,
+ FlutwireIntercept,
+ FlutwireRedirect,
+ FlutwireRequest,
+ FlutwireResponse,
+ FlutwireSource,
+ FlutwireStatusClass;
+export 'src/model/headers.dart' show FlutwireHeaders;
+export 'src/model/message.dart'
+ show
+ FlutwireConversationKind,
+ FlutwireMessage,
+ FlutwireMessageKind,
+ FlutwireMessages;
+export 'src/model/multipart.dart' show FlutwireMultipartPart, parseMultipart;
+export 'src/store/preferences.dart'
+ show FlutwirePreferences, MemoryFlutwirePreferences;
+export 'src/store/store.dart' show FlutwireStore, MemoryFlutwireStore;
diff --git a/packages/flutwire/lib/src/capture/recorder.dart b/packages/flutwire/lib/src/capture/recorder.dart
index 861e55e..be048db 100644
--- a/packages/flutwire/lib/src/capture/recorder.dart
+++ b/packages/flutwire/lib/src/capture/recorder.dart
@@ -206,14 +206,6 @@ final class FlutwireCallRecorder {
_publishMessages(force: false);
});
- /// Passes [source], the bytes of the request body, through unchanged while
- /// recording them. Use it for a body that is only available as a stream.
- Stream> observeRequest(Stream> source) =>
- source.map((chunk) {
- requestBody(chunk);
- return chunk;
- });
-
/// Passes [source], the bytes of the response body, through unchanged while
/// recording them, and ends the call when the stream ends, fails, or is
/// cancelled by the app.
diff --git a/packages/flutwire/lib/src/decode/protobuf/schema.dart b/packages/flutwire/lib/src/decode/protobuf/schema.dart
index 19aec25..3b4db2a 100644
--- a/packages/flutwire/lib/src/decode/protobuf/schema.dart
+++ b/packages/flutwire/lib/src/decode/protobuf/schema.dart
@@ -263,7 +263,7 @@ final class ProtoSchema {
final values = {};
for (final value in _nested(enumeration, 2)) {
final name = _string(value, 1);
- final number = _int(value, 2);
+ final number = _int32(value, 2);
if (name != null && number != null) {
values.putIfAbsent(number, () => name);
}
@@ -295,6 +295,17 @@ final class ProtoSchema {
return name.startsWith('.') ? name.substring(1) : name;
}
+ /// A varint field that holds an `int32`. A negative one is written as ten
+ /// bytes, which no positive `int` holds, so it is read through [signed32].
+ static int? _int32(List fields, int number) {
+ for (final f in fields) {
+ if (f.number == number && f.type == WireType.varint) {
+ return signed32(f.value);
+ }
+ }
+ return null;
+ }
+
static int? _int(List fields, int number) {
for (final f in fields) {
if (f.number == number && f.type == WireType.varint) {
diff --git a/packages/flutwire/lib/src/decode/protobuf/values.dart b/packages/flutwire/lib/src/decode/protobuf/values.dart
index 0c313d9..bfc5164 100644
--- a/packages/flutwire/lib/src/decode/protobuf/values.dart
+++ b/packages/flutwire/lib/src/decode/protobuf/values.dart
@@ -211,7 +211,7 @@ final class ProtoValues {
static Object? _fixed(ProtoType type, Uint8List bytes) {
final view = ByteData.sublistView(bytes);
return switch (type) {
- ProtoType.float => view.getFloat32(0, Endian.little),
+ ProtoType.float => _shortest(view.getFloat32(0, Endian.little)),
ProtoType.double => view.getFloat64(0, Endian.little),
ProtoType.sfixed32 => view.getInt32(0, Endian.little),
ProtoType.sfixed64 => signedFixed64(bytes),
@@ -219,6 +219,20 @@ final class ProtoValues {
};
}
+ /// The shortest decimal that reads back as the same `float`, which is how
+ /// JSON writes one: 0.1 for the float nearest to a tenth, not the 17 digits
+ /// of the double it widens to.
+ static double _shortest(double value) {
+ if (!value.isFinite || value == 0) return value;
+ final probe = ByteData(4);
+ for (var digits = 1; digits <= 9; digits++) {
+ final candidate = double.parse(value.toStringAsPrecision(digits));
+ probe.setFloat32(0, candidate);
+ if (probe.getFloat32(0) == value) return candidate;
+ }
+ return value;
+ }
+
/// A field read with no help from a schema.
Object? _raw(WireField field, int depth) {
switch (field.type) {
diff --git a/packages/flutwire/lib/src/dock.dart b/packages/flutwire/lib/src/dock.dart
index 3e36a54..c76bb54 100644
--- a/packages/flutwire/lib/src/dock.dart
+++ b/packages/flutwire/lib/src/dock.dart
@@ -49,6 +49,11 @@ final class FlutwireDock extends ChangeNotifier {
/// room.
static const double wideFrom = 900;
+ /// The narrowest screen with room for the panel and the app side by side.
+ /// Narrower than this the panel goes at the bottom whatever side was chosen,
+ /// because beside the app it would leave neither of them a usable width.
+ static const double besideFrom = 600;
+
final FlutwirePreferences? _preferences;
FlutwireDockSide? _side;
FlutwireDockSide _resolved = FlutwireDockSide.bottom;
@@ -56,6 +61,7 @@ final class FlutwireDock extends ChangeNotifier {
bool _prefersDock;
bool _isOpen = false;
int _hosts = 0;
+ double _screenWidth = double.infinity;
/// Whether the panel is on screen.
bool get isOpen => _isOpen;
@@ -77,9 +83,16 @@ final class FlutwireDock extends ChangeNotifier {
/// The side the panel was last laid out on.
FlutwireDockSide get side => _resolved;
+ /// Whether the last screen the panel was laid out on has room for it beside
+ /// the app.
+ bool get canSitBeside => _screenWidth >= besideFrom;
+
/// The side for a screen of [size]: the one the user chose, or by the shape of
- /// the screen.
+ /// the screen, and always the bottom when the screen is too narrow for the
+ /// panel to sit beside the app.
FlutwireDockSide sideFor(Size size) {
+ _screenWidth = size.width;
+ if (!canSitBeside) return _resolved = FlutwireDockSide.bottom;
return _resolved =
_side ??
(size.width >= wideFrom
@@ -122,8 +135,10 @@ final class FlutwireDock extends ChangeNotifier {
notifyListeners();
}
- /// Moves the panel to the other side of the app.
+ /// Moves the panel to the other side of the app. Does nothing where the
+ /// screen has no room for it beside the app.
void swapSide() {
+ if (_resolved == FlutwireDockSide.bottom && !canSitBeside) return;
_side = _resolved == FlutwireDockSide.right
? FlutwireDockSide.bottom
: FlutwireDockSide.right;
diff --git a/packages/flutwire/lib/src/engine.dart b/packages/flutwire/lib/src/engine.dart
index baba23c..a8e18ed 100644
--- a/packages/flutwire/lib/src/engine.dart
+++ b/packages/flutwire/lib/src/engine.dart
@@ -279,7 +279,7 @@ final class FlutwireEngine implements FlutwireRecorderHost {
}
/// The store of an engine that forwards what it records: it keeps nothing.
-final class _ForwardedStore implements FlutwireStore {
+final class _ForwardedStore extends FlutwireStore {
final ValueNotifier> _calls = ValueNotifier(const []);
@override
diff --git a/packages/flutwire/lib/src/export/export.dart b/packages/flutwire/lib/src/export/export.dart
index bf7e5fb..0637833 100644
--- a/packages/flutwire/lib/src/export/export.dart
+++ b/packages/flutwire/lib/src/export/export.dart
@@ -2,6 +2,7 @@ import 'dart:convert';
import '../model/body.dart';
import '../model/call.dart';
+import '../version.dart';
import 'curl.dart';
import 'har.dart';
import 'text.dart';
@@ -34,8 +35,13 @@ enum FlutwireExportFormat {
/// Writes [calls] in this format. The calls must have their bodies loaded.
///
/// Text keeps the order it is given, which is how the inspector lists them.
- /// Every other format is a replayable sequence, so it runs oldest first.
- String render(Iterable calls, {String version = '0'}) {
+ /// Every other format is a replayable sequence, so it runs oldest first. The
+ /// HAR names [version], which is this package's own unless told otherwise,
+ /// as the version of its creator.
+ String render(
+ Iterable calls, {
+ String version = flutwireVersion,
+ }) {
final listed = calls.toList();
final oldestFirst = [...listed]..sort((a, b) => a.id.compareTo(b.id));
const indented = JsonEncoder.withIndent(' ');
diff --git a/packages/flutwire/lib/src/store/disk_store_io.dart b/packages/flutwire/lib/src/store/disk_store_io.dart
index 457ee4f..6e6b546 100644
--- a/packages/flutwire/lib/src/store/disk_store_io.dart
+++ b/packages/flutwire/lib/src/store/disk_store_io.dart
@@ -17,6 +17,7 @@ import 'store.dart';
/// Layout, under the folder it is given:
///
/// ```
+/// layout the number of this layout, which is [currentLayout]
/// calls/.json the summary of a call: headers, status, timings
/// bodies/.req the request body bytes that were kept
/// bodies/.res the response body bytes that were kept
@@ -24,6 +25,11 @@ import 'store.dart';
/// preferences.json what the user picked in the settings
/// ```
///
+/// A folder with no `layout` file is new, or was written by a release from
+/// before the file existed, whose layout is the first. A folder whose layout is
+/// newer than this release knows is not opened at all, so that a release never
+/// reads another's files wrongly or sweeps away what it cannot read.
+///
/// A call is written when it ends, bodies and messages first and summary last,
/// through a temporary file, so a summary on disk always has its bodies and a
/// crash leaves at worst an orphan body, which the next start sweeps away.
@@ -45,14 +51,21 @@ final class DiskFlutwireStore extends MemoryFlutwireStore {
preferences: _preferences,
);
+ /// The layout this release writes, and the newest one it reads.
+ static const int currentLayout = 1;
+
/// Opens the history stored under [root], creating it if needed, and applies
/// the retention policy and the limits to what it finds.
+ ///
+ /// Throws a [FormatException] when the folder has a layout this release does
+ /// not know, and leaves what is in it as it is.
static Future open(
Directory root,
FlutwireConfig config, {
DateTime Function()? clock,
Duration notifyInterval = const Duration(milliseconds: 120),
}) async {
+ await _checkLayout(root);
final store = DiskFlutwireStore._(
root,
config,
@@ -80,6 +93,31 @@ final class DiskFlutwireStore extends MemoryFlutwireStore {
/// Completes when every write and delete asked for so far has finished.
Future get settled => _queue;
+ /// Records the layout in a folder that has none, and refuses a folder whose
+ /// layout is one this release cannot read.
+ static Future _checkLayout(Directory root) async {
+ await root.create(recursive: true);
+ final file = File('${root.path}/layout');
+ final String text;
+ try {
+ text = (await file.readAsString()).trim();
+ } on PathNotFoundException {
+ // Through a temporary file, so that a crash cannot leave a marker with
+ // nothing in it, which would shut the history out for good.
+ final temporary = File('${root.path}/layout.tmp');
+ await temporary.writeAsString('$currentLayout\n', flush: true);
+ await temporary.rename(file.path);
+ return;
+ }
+ final found = int.tryParse(text);
+ if (found == null || found < 1 || found > currentLayout) {
+ throw FormatException(
+ 'The history in ${root.path} has the layout "$text", and this '
+ 'release reads layout $currentLayout. It was left as it is.',
+ );
+ }
+ }
+
@override
void onPut(FlutwireCall call) {
if (call.isPending) return;
diff --git a/packages/flutwire/lib/src/store/preferences.dart b/packages/flutwire/lib/src/store/preferences.dart
index 88dfcb4..58a7793 100644
--- a/packages/flutwire/lib/src/store/preferences.dart
+++ b/packages/flutwire/lib/src/store/preferences.dart
@@ -4,7 +4,7 @@
/// history does, and clearing the history never touches them. A preference is
/// never worth failing for: a store that cannot write one keeps it for the
/// session and says nothing.
-abstract interface class FlutwirePreferences {
+abstract base class FlutwirePreferences {
/// What was last written under [key], or null if nothing was.
String? read(String key);
@@ -13,7 +13,7 @@ abstract interface class FlutwirePreferences {
}
/// Preferences that last as long as the process.
-final class MemoryFlutwirePreferences implements FlutwirePreferences {
+final class MemoryFlutwirePreferences extends FlutwirePreferences {
/// Creates preferences holding [initial].
MemoryFlutwirePreferences([Map initial = const {}])
: _values = {...initial};
diff --git a/packages/flutwire/lib/src/store/preferences_io.dart b/packages/flutwire/lib/src/store/preferences_io.dart
index c66b538..f3ea5a0 100644
--- a/packages/flutwire/lib/src/store/preferences_io.dart
+++ b/packages/flutwire/lib/src/store/preferences_io.dart
@@ -8,7 +8,7 @@ import 'preferences.dart';
/// The file is rewritten whole through a temporary one, so a crash leaves
/// either the old preferences or the new ones, and a file that cannot be read
/// is treated as empty rather than as an error.
-final class FileFlutwirePreferences implements FlutwirePreferences {
+final class FileFlutwirePreferences extends FlutwirePreferences {
FileFlutwirePreferences._(this._file, this._values);
/// Reads the preferences under [root], or starts empty.
diff --git a/packages/flutwire/lib/src/store/preferences_web.dart b/packages/flutwire/lib/src/store/preferences_web.dart
index c7e6bcf..7f43102 100644
--- a/packages/flutwire/lib/src/store/preferences_web.dart
+++ b/packages/flutwire/lib/src/store/preferences_web.dart
@@ -14,7 +14,7 @@ external _Storage get _localStorage;
/// Preferences kept in the browser's `localStorage`, under a prefix of their
/// own. Where the browser refuses storage, such as in a private window, they
/// last for the session.
-final class WebFlutwirePreferences implements FlutwirePreferences {
+final class WebFlutwirePreferences extends FlutwirePreferences {
/// Creates preferences backed by `localStorage`.
WebFlutwirePreferences();
diff --git a/packages/flutwire/lib/src/store/store.dart b/packages/flutwire/lib/src/store/store.dart
index c507383..11b45d7 100644
--- a/packages/flutwire/lib/src/store/store.dart
+++ b/packages/flutwire/lib/src/store/store.dart
@@ -8,9 +8,9 @@ import 'preferences.dart';
/// Where recorded calls are kept.
///
/// The inspector and the notification read from a store, and the recorders
-/// write to it. A store is a plain object, so tests and apps can substitute
-/// their own.
-abstract interface class FlutwireStore {
+/// write to it. Flutwire makes the store itself and an app reads the history
+/// through `Flutwire.store`, so this type is not something to implement.
+abstract base class FlutwireStore {
/// The history, newest first.
///
/// A new list is published when the history changes, and changes that arrive
@@ -50,7 +50,7 @@ abstract interface class FlutwireStore {
/// It enforces the call limit and the memory budget by dropping the oldest
/// calls that are not pinned. The store that keeps the history on disk builds
/// on it.
-class MemoryFlutwireStore implements FlutwireStore {
+base class MemoryFlutwireStore extends FlutwireStore {
/// Creates an empty store.
MemoryFlutwireStore({
this.maxCalls = 500,
diff --git a/packages/flutwire/lib/src/ui/inspector/app_bars.dart b/packages/flutwire/lib/src/ui/inspector/app_bars.dart
index 7a6dae9..55f97e1 100644
--- a/packages/flutwire/lib/src/ui/inspector/app_bars.dart
+++ b/packages/flutwire/lib/src/ui/inspector/app_bars.dart
@@ -119,14 +119,16 @@ class InspectorAppBar extends StatelessWidget implements PreferredSizeWidget {
// scrolls inside it.
if (docked) ...[
PopupMenuItem(value: onUndock, child: Text(s.undockInspector)),
- PopupMenuItem(
- value: session.dock.swapSide,
- child: Text(
- session.dock.side == FlutwireDockSide.right
- ? s.dockToBottom
- : s.dockToRight,
+ if (session.dock.side == FlutwireDockSide.right)
+ PopupMenuItem(
+ value: session.dock.swapSide,
+ child: Text(s.dockToBottom),
+ )
+ else if (session.dock.canSitBeside)
+ PopupMenuItem(
+ value: session.dock.swapSide,
+ child: Text(s.dockToRight),
),
- ),
] else if (session.dock.canDock)
PopupMenuItem(value: onDock, child: Text(s.dockInspector)),
const PopupMenuDivider(),
@@ -197,9 +199,13 @@ class SelectionAppBar extends StatelessWidget implements PreferredSizeWidget {
icon: const Icon(Icons.close_rounded),
onPressed: controller.clearSelection,
),
- title: Text(
- s.selected(controller.selection.length),
- style: t.display(size: 18),
+ title: FittedBox(
+ fit: BoxFit.scaleDown,
+ alignment: AlignmentDirectional.centerStart,
+ child: Text(
+ s.selected(controller.selection.length),
+ style: t.display(size: 18),
+ ),
),
actions: [
IconButton(
diff --git a/packages/flutwire/lib/src/ui/widgets/metric_strip.dart b/packages/flutwire/lib/src/ui/widgets/metric_strip.dart
index d6ff90e..beabb4d 100644
--- a/packages/flutwire/lib/src/ui/widgets/metric_strip.dart
+++ b/packages/flutwire/lib/src/ui/widgets/metric_strip.dart
@@ -1,3 +1,5 @@
+import 'dart:math' as math;
+
import 'package:flutter/material.dart';
import '../../model/call.dart';
@@ -8,6 +10,9 @@ import '../scope.dart';
/// Four numbers that say how a session is going: how many calls, how many went
/// wrong, how long they take on average, and how much they moved. The statistics
/// page has the rest.
+///
+/// They stand in a row when each has the room for its label, and in two rows of
+/// two when the screen, the panel or the text size does not give it.
class MetricStrip extends StatelessWidget {
/// Creates the strip for [calls].
const MetricStrip({super.key, required this.calls});
@@ -15,6 +20,11 @@ class MetricStrip extends StatelessWidget {
/// The calls the numbers are about.
final List calls;
+ static const double _labelSize = 9.5;
+
+ /// What a label needs besides its own width, so that two never touch.
+ static const double _gap = 4;
+
@override
Widget build(BuildContext context) {
final t = context.fw;
@@ -22,28 +32,64 @@ class MetricStrip extends StatelessWidget {
final stats = SessionStats.of(calls, slowest: 0);
final failed = stats.failed;
final average = stats.average;
+ final metrics = [
+ _Metric(label: s.statCalls, value: '${stats.total}'),
+ _Metric(
+ label: s.statFailed,
+ value: '$failed',
+ color: failed > 0 ? t.error : null,
+ ),
+ _Metric(
+ label: s.statAverage,
+ value: average == null ? '·' : formatDuration(average),
+ ),
+ _Metric(
+ label: s.statTransferred,
+ value: formatBytes(stats.bytesTransferred),
+ ),
+ ];
+ final widest = _widestLabel(context, [for (final m in metrics) m.label]);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
- child: Row(
- children: [
- _Metric(label: s.statCalls, value: '${stats.total}'),
- _Metric(
- label: s.statFailed,
- value: '$failed',
- color: failed > 0 ? t.error : null,
- ),
- _Metric(
- label: s.statAverage,
- value: average == null ? '·' : formatDuration(average),
- ),
- _Metric(
- label: s.statTransferred,
- value: formatBytes(stats.bytesTransferred),
- ),
- ],
+ child: LayoutBuilder(
+ builder: (context, box) {
+ if (box.maxWidth / metrics.length >= widest + _gap) {
+ return Row(children: [for (final m in metrics) Expanded(child: m)]);
+ }
+ return Column(
+ children: [
+ Row(
+ children: [for (final m in metrics.take(2)) Expanded(child: m)],
+ ),
+ const SizedBox(height: 10),
+ Row(
+ children: [for (final m in metrics.skip(2)) Expanded(child: m)],
+ ),
+ ],
+ );
+ },
),
);
}
+
+ /// The width of the longest of [labels] as the strip writes them.
+ static double _widestLabel(BuildContext context, List labels) {
+ final style = context.fw.label(size: _labelSize);
+ final scaler = MediaQuery.textScalerOf(context);
+ final direction = Directionality.of(context);
+ var widest = 0.0;
+ for (final label in labels) {
+ final painter = TextPainter(
+ text: TextSpan(text: label.toUpperCase(), style: style),
+ textDirection: direction,
+ textScaler: scaler,
+ maxLines: 1,
+ )..layout();
+ widest = math.max(widest, painter.width);
+ painter.dispose();
+ }
+ return widest;
+ }
}
class _Metric extends StatelessWidget {
@@ -56,27 +102,31 @@ class _Metric extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.fw;
- return Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(label.toUpperCase(), style: t.label(size: 9.5)),
- const SizedBox(height: 4),
- FittedBox(
- fit: BoxFit.scaleDown,
- alignment: Alignment.centerLeft,
- child: Text(
- value,
- style: t.mono(
- size: 18,
- weight: FontWeight.w600,
- color: color ?? t.ink,
- height: 1.15,
- ),
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label.toUpperCase(),
+ style: t.label(size: MetricStrip._labelSize),
+ maxLines: 1,
+ softWrap: false,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 4),
+ FittedBox(
+ fit: BoxFit.scaleDown,
+ alignment: Alignment.centerLeft,
+ child: Text(
+ value,
+ style: t.mono(
+ size: 18,
+ weight: FontWeight.w600,
+ color: color ?? t.ink,
+ height: 1.15,
),
),
- ],
- ),
+ ),
+ ],
);
}
}
diff --git a/packages/flutwire/lib/src/version.dart b/packages/flutwire/lib/src/version.dart
index feabdee..0e8e6c0 100644
--- a/packages/flutwire/lib/src/version.dart
+++ b/packages/flutwire/lib/src/version.dart
@@ -1,3 +1,3 @@
/// The version of this package. A test keeps it equal to the one in the
/// pubspec, so a release cannot change one and forget the other.
-const String flutwireVersion = '0.2.0';
+const String flutwireVersion = '1.0.0';
diff --git a/packages/flutwire/pubspec.yaml b/packages/flutwire/pubspec.yaml
index 96d9910..7b52995 100644
--- a/packages/flutwire/pubspec.yaml
+++ b/packages/flutwire/pubspec.yaml
@@ -2,7 +2,7 @@ name: flutwire
description: >-
Network inspector for Flutter. Records the HTTP calls your app makes, keeps
them across restarts, and opens them from a notification in the shade.
-version: 0.2.0
+version: 1.0.0
repository: https://github.com/NaCode-Studios/Flutwire
issue_tracker: https://github.com/NaCode-Studios/Flutwire/issues
funding:
@@ -28,10 +28,14 @@ screenshots:
path: screenshots/search.png
- description: Credentials are masked when a call is recorded, so they never reach the disk.
path: screenshots/headers.png
- - description: XML, HTML and text are colour-coded, and forms, images and bytes have their own views.
- path: screenshots/body-xml.png
- - description: The light look.
- path: screenshots/list-light.png
+ - description: Two calls side by side, with each difference marked where it happens.
+ path: screenshots/compare.png
+ - description: Error rate, percentiles and the slowest calls, with a timeline.
+ path: screenshots/statistics.png
+ - description: The inspector docked under the app, with a request held at a breakpoint.
+ path: screenshots/dock.png
+ - description: Mocks and breakpoints. A line says what is in force and every call a rule touched is marked.
+ path: screenshots/rules.png
- description: The same notification on iOS, on the lock screen.
path: screenshots/lock-screen-ios.jpg
diff --git a/packages/flutwire/screenshots/body-xml.png b/packages/flutwire/screenshots/body-xml.png
deleted file mode 100644
index 6fbb9e4..0000000
Binary files a/packages/flutwire/screenshots/body-xml.png and /dev/null differ
diff --git a/packages/flutwire/screenshots/compare.png b/packages/flutwire/screenshots/compare.png
new file mode 100644
index 0000000..0bbb85e
Binary files /dev/null and b/packages/flutwire/screenshots/compare.png differ
diff --git a/packages/flutwire/screenshots/dock.png b/packages/flutwire/screenshots/dock.png
new file mode 100644
index 0000000..d61e68a
Binary files /dev/null and b/packages/flutwire/screenshots/dock.png differ
diff --git a/packages/flutwire/screenshots/list-light.png b/packages/flutwire/screenshots/list-light.png
deleted file mode 100644
index a12f2d6..0000000
Binary files a/packages/flutwire/screenshots/list-light.png and /dev/null differ
diff --git a/packages/flutwire/screenshots/rules.png b/packages/flutwire/screenshots/rules.png
new file mode 100644
index 0000000..4fdf0f3
Binary files /dev/null and b/packages/flutwire/screenshots/rules.png differ
diff --git a/packages/flutwire/screenshots/statistics.png b/packages/flutwire/screenshots/statistics.png
new file mode 100644
index 0000000..852c807
Binary files /dev/null and b/packages/flutwire/screenshots/statistics.png differ
diff --git a/packages/flutwire/test/capture/recorder_test.dart b/packages/flutwire/test/capture/recorder_test.dart
index 682f001..c6f5bcf 100644
--- a/packages/flutwire/test/capture/recorder_test.dart
+++ b/packages/flutwire/test/capture/recorder_test.dart
@@ -386,7 +386,7 @@ void main() {
});
}
-class _ExplodingStore extends MemoryFlutwireStore {
+final class _ExplodingStore extends MemoryFlutwireStore {
@override
void put(FlutwireCall call) => throw StateError('disk full');
}
diff --git a/packages/flutwire/test/decode/protobuf_protoc_test.dart b/packages/flutwire/test/decode/protobuf_protoc_test.dart
new file mode 100644
index 0000000..c57d51b
--- /dev/null
+++ b/packages/flutwire/test/decode/protobuf_protoc_test.dart
@@ -0,0 +1,209 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutwire/flutwire.dart';
+
+// What the other Protobuf tests read is built by hand, with an encoder written
+// for them, so a misreading of the format could sit on both sides. This one
+// reads what protoc itself wrote: the descriptor set, and messages serialised
+// by the code protoc generates. `test/fixtures/protoc/make.py` writes them.
+
+Uint8List fixture(String name) =>
+ File('test/fixtures/protoc/$name').readAsBytesSync();
+
+FlutwireCall call(String path) => FlutwireCall(
+ id: 1,
+ method: 'POST',
+ uri: Uri.parse('https://api.test$path'),
+ source: FlutwireSource.io,
+ startedAt: DateTime.utc(2026, 10, 24),
+ request: FlutwireRequest(
+ headers: FlutwireHeaders.empty,
+ body: FlutwireBody.empty(),
+ ),
+);
+
+String? decode(
+ FlutwireBodyDecoder decoder,
+ List bytes, {
+ String path = '/x',
+ String type = 'application/x-protobuf',
+ bool isRequest = false,
+}) => decoder.decode(
+ call(path),
+ FlutwireBody(bytes: Uint8List.fromList(bytes), contentType: type),
+ isRequest: isRequest,
+);
+
+List frame(List payload) => [
+ 0,
+ (payload.length >> 24) & 0xff,
+ (payload.length >> 16) & 0xff,
+ (payload.length >> 8) & 0xff,
+ payload.length & 0xff,
+ ...payload,
+];
+
+void main() {
+ final descriptors = fixture('check.desc');
+ FlutwireProtobufDecoder reading(String type) => FlutwireProtobufDecoder(
+ descriptorSet: descriptors,
+ messageType: (call, {required isRequest}) => type,
+ );
+ final club = reading('check.v1.Club');
+
+ test('every scalar type comes out as it went in', () {
+ final text = decode(reading('check.v1.Scalars'), fixture('scalars.bin'))!;
+
+ expect(
+ text,
+ '{\n'
+ ' "d": 3.141592653589793,\n'
+ ' "f": 0.1,\n'
+ ' "i32": -2147483648,\n'
+ ' "i64": -9223372036854775808,\n'
+ ' "u32": 4294967295,\n'
+ ' "u64": 18446744073709551615,\n'
+ ' "s32": -1,\n'
+ ' "s64": -9007199254740993,\n'
+ ' "fx32": 4294967295,\n'
+ ' "fx64": 18446744073709551615,\n'
+ ' "sf32": -5,\n'
+ ' "sf64": -6,\n'
+ ' "b": true,\n'
+ ' "s": "héllo ☃ \u{1F600}",\n'
+ ' "raw": "01 02 ff"\n'
+ '}',
+ );
+ });
+
+ test('a message with every kind of field comes out whole', () {
+ final text = decode(club, fixture('club.bin'))!;
+
+ expect(
+ text,
+ '{\n'
+ ' "name": "Hikers",\n'
+ ' "level": "LEVEL_HIGH",\n'
+ ' "tags": ["outdoor", "weekend"],\n'
+ ' "scores": [3, 5, 8, -1],\n'
+ ' "ratios": [0.5, 1.25],\n'
+ ' "counts": {\n'
+ ' "x": 1,\n'
+ ' "y": 2\n'
+ ' },\n'
+ ' "members": {\n'
+ ' "2": {\n'
+ ' "handle": "bob"\n'
+ ' },\n'
+ ' "1": {\n'
+ ' "handle": "ada",\n'
+ ' "admin": true\n'
+ ' }\n'
+ ' },\n'
+ ' "address": {\n'
+ ' "city": "London",\n'
+ ' "street": "St James\'s Square"\n'
+ ' },\n'
+ ' "branches": [\n'
+ ' {\n'
+ ' "city": "Bath"\n'
+ ' },\n'
+ ' {\n'
+ ' "city": "Leeds"\n'
+ ' }\n'
+ ' ],\n'
+ ' "email": "club@example.com",\n'
+ ' "rank": 0,\n'
+ ' "founded": "2023-11-14T22:13:20.500Z",\n'
+ ' "season": "7776000.0015s",\n'
+ ' "levels": ["LEVEL_LOW", "LEVEL_NEGATIVE", "LEVEL_HIGH"],\n'
+ ' "kind": "KIND_TEAM",\n'
+ ' "scalars": {\n'
+ ' "i32": 7,\n'
+ ' "s": "nested"\n'
+ ' }\n'
+ '}',
+ );
+ });
+
+ test('an enum with a negative value is shown by name', () {
+ // A negative enum is written as ten bytes, like a negative int32, and the
+ // descriptor writes its number the same way.
+ expect(decode(club, fixture('club.bin')), contains('"LEVEL_NEGATIVE"'));
+ });
+
+ test('a oneof shows the member that was set, and only that one', () {
+ expect(
+ decode(club, fixture('club_phone.bin')),
+ '{\n "name": "Phone",\n "phone": 442079460000\n}',
+ );
+ expect(
+ decode(club, fixture('club_office.bin')),
+ '{\n "name": "Office",\n "office": {\n "city": "Leeds"\n }\n}',
+ );
+ });
+
+ test('a field the schema does not have is shown by its number', () {
+ expect(
+ decode(club, fixture('club_unknown.bin')),
+ '{\n "name": "Hikers",\n "": 5\n}',
+ );
+ });
+
+ test('a proto2 message reads a list written loose and one packed', () {
+ expect(
+ decode(reading('check.legacy.Old'), fixture('old.bin')),
+ '{\n'
+ ' "name": "Ada",\n'
+ ' "loose": [1, 2, 3],\n'
+ ' "tight": [4, 5, 6],\n'
+ ' "blob": "00 01"\n'
+ '}',
+ );
+ });
+
+ group('over gRPC', () {
+ // No message type is named: the method in the address says which it is.
+ final grpc = FlutwireProtobufDecoder(descriptorSet: descriptors);
+
+ test('a unary call finds the types of its method', () {
+ expect(
+ decode(
+ grpc,
+ frame(fixture('get_request.bin')),
+ path: '/check.v1.Clubs/Get',
+ type: 'application/grpc',
+ isRequest: true,
+ ),
+ '{\n "name": "Hikers"\n}',
+ );
+ expect(
+ decode(
+ grpc,
+ frame(fixture('club_phone.bin')),
+ path: '/check.v1.Clubs/Get',
+ type: 'application/grpc',
+ ),
+ '{\n "name": "Phone",\n "phone": 442079460000\n}',
+ );
+ });
+
+ test('a server stream lists its messages', () {
+ final text = decode(
+ grpc,
+ [
+ ...frame(fixture('club_phone.bin')),
+ ...frame(fixture('club_office.bin')),
+ ],
+ path: '/check.v1.Clubs/List',
+ type: 'application/grpc',
+ )!;
+
+ expect(text, contains('"name": "Phone"'));
+ expect(text, contains('"name": "Office"'));
+ expect(text, contains('"city": "Leeds"'));
+ });
+ });
+}
diff --git a/packages/flutwire/test/dock_test.dart b/packages/flutwire/test/dock_test.dart
index 86b52ba..7872b99 100644
--- a/packages/flutwire/test/dock_test.dart
+++ b/packages/flutwire/test/dock_test.dart
@@ -73,14 +73,46 @@ void main() {
});
test('moves to the other side when asked, and then keeps it there', () {
- dock.sideFor(const Size(420, 900));
+ dock.sideFor(const Size(720, 900));
dock.swapSide();
- expect(dock.sideFor(const Size(420, 900)), FlutwireDockSide.right);
+ expect(dock.sideFor(const Size(720, 900)), FlutwireDockSide.right);
dock.swapSide();
expect(dock.sideFor(const Size(1280, 800)), FlutwireDockSide.bottom);
});
+ test('goes under the app on a screen too narrow to sit beside it', () {
+ expect(dock.sideFor(const Size(599, 900)), FlutwireDockSide.bottom);
+ expect(dock.canSitBeside, isFalse);
+
+ expect(dock.sideFor(const Size(600, 900)), FlutwireDockSide.bottom);
+ expect(dock.canSitBeside, isTrue);
+ });
+
+ test('is not moved to the right of a screen with no room for it', () {
+ dock.sideFor(const Size(420, 900));
+
+ dock.swapSide();
+
+ expect(dock.sideFor(const Size(420, 900)), FlutwireDockSide.bottom);
+ // Nothing was chosen, so a wide screen still decides by its own shape.
+ expect(dock.sideFor(const Size(1280, 800)), FlutwireDockSide.right);
+ });
+
+ test(
+ 'stays at the bottom of a narrow screen after the right was chosen',
+ () {
+ dock.sideFor(const Size(1280, 800));
+ dock.swapSide();
+ dock.swapSide();
+ expect(dock.sideFor(const Size(1280, 800)), FlutwireDockSide.right);
+
+ expect(dock.sideFor(const Size(420, 900)), FlutwireDockSide.bottom);
+ // The choice was only overridden, and holds again where it can.
+ expect(dock.sideFor(const Size(1280, 800)), FlutwireDockSide.right);
+ },
+ );
+
test('is resized within limits', () {
dock.resizeBy(1);
expect(dock.extent, FlutwireDock.maxExtent);
@@ -148,7 +180,7 @@ void main() {
expect(dock.wantsDock, isTrue);
expect(dock.isOpen, isFalse);
expect(dock.extent, 0.55);
- expect(dock.sideFor(const Size(420, 900)), FlutwireDockSide.right);
+ expect(dock.sideFor(const Size(720, 900)), FlutwireDockSide.right);
});
test('forget the dock once the inspector is left full screen', () {
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/bodies/1.req b/packages/flutwire/test/fixtures/history_0_2_0/bodies/1.req
new file mode 100644
index 0000000..075cbae
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/bodies/1.req
@@ -0,0 +1 @@
+{"items":[{"sku":"A-12","qty":2}]}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/bodies/1.res b/packages/flutwire/test/fixtures/history_0_2_0/bodies/1.res
new file mode 100644
index 0000000..a52f458
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/bodies/1.res
@@ -0,0 +1 @@
+{"id":"o-77","total":42}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/bodies/2.res b/packages/flutwire/test/fixtures/history_0_2_0/bodies/2.res
new file mode 100644
index 0000000..e298754
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/bodies/2.res
@@ -0,0 +1 @@
+no such club
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/bodies/3.req b/packages/flutwire/test/fixtures/history_0_2_0/bodies/3.req
new file mode 100644
index 0000000..cb3c048
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/bodies/3.req
@@ -0,0 +1 @@
+{"query":"query Clubs { clubs { id } }"}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/bodies/5.req b/packages/flutwire/test/fixtures/history_0_2_0/bodies/5.req
new file mode 100644
index 0000000..93bb17b
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/bodies/5.req
@@ -0,0 +1 @@
+{"items":[{"sku":"A-12","qty":3}]}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/bodies/5.res b/packages/flutwire/test/fixtures/history_0_2_0/bodies/5.res
new file mode 100644
index 0000000..edee0b2
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/bodies/5.res
@@ -0,0 +1 @@
+{"id":"o-78","total":63}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/bodies/6.res b/packages/flutwire/test/fixtures/history_0_2_0/bodies/6.res
new file mode 100644
index 0000000..b5754e2
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/bodies/6.res
@@ -0,0 +1 @@
+ok
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/calls/1.json b/packages/flutwire/test/fixtures/history_0_2_0/calls/1.json
new file mode 100644
index 0000000..5cfa093
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/calls/1.json
@@ -0,0 +1 @@
+{"v":1,"id":1,"method":"POST","uri":"https://api.example.com/v1/orders","source":"io","state":"completed","startedAt":"2026-10-20T09:30:00.000Z","firstByteAt":"2026-10-20T09:30:00.090Z","endedAt":"2026-10-20T09:30:00.148Z","request":{"headers":[["content-type","application/json"],["authorization","[redacted]"]],"body":{"total":34,"kept":34,"type":"application/json"}},"response":{"status":201,"reason":"Created","headers":[["content-type","application/json"]],"body":{"total":24,"kept":24,"type":"application/json"},"remoteAddress":"203.0.113.7","remotePort":443,"certificate":null,"redirects":[],"compression":null},"error":null,"graphql":null,"pinned":true}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/calls/2.json b/packages/flutwire/test/fixtures/history_0_2_0/calls/2.json
new file mode 100644
index 0000000..dad0b51
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/calls/2.json
@@ -0,0 +1 @@
+{"v":1,"id":2,"method":"GET","uri":"https://api.example.com/v1/clubs/9","source":"dio","state":"completed","startedAt":"2026-10-20T09:30:05.000Z","firstByteAt":null,"endedAt":"2026-10-20T09:30:05.096Z","request":{"headers":[],"body":{"total":0,"kept":0,"type":null}},"response":{"status":404,"reason":"Not Found","headers":[["content-type","text/plain"]],"body":{"total":12,"kept":12,"type":"text/plain"},"remoteAddress":null,"remotePort":null,"certificate":{"subject":"CN=api.example.com","issuer":"CN=Example CA","from":"2026-01-01T00:00:00.000Z","to":"2027-01-01T00:00:00.000Z","sha1":"AB:CD"},"redirects":[{"status":301,"method":"GET","location":"https://api.example.com/v1/clubs/9/"}],"compression":"gzip"},"error":null,"graphql":null,"pinned":false}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/calls/3.json b/packages/flutwire/test/fixtures/history_0_2_0/calls/3.json
new file mode 100644
index 0000000..6ba8725
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/calls/3.json
@@ -0,0 +1 @@
+{"v":1,"id":3,"method":"POST","uri":"https://api.example.com/graphql","source":"http","state":"failed","startedAt":"2026-10-20T09:30:09.000Z","firstByteAt":null,"endedAt":"2026-10-20T09:30:09.030Z","request":{"headers":[["content-type","application/json"]],"body":{"total":40,"kept":40,"type":"application/json"}},"error":{"type":"SocketException","message":"Connection reset by peer","stack":"#0 main (file:///app.dart:1:1)"},"graphql":{"type":"query","name":"Clubs"},"pinned":false}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/calls/4.json b/packages/flutwire/test/fixtures/history_0_2_0/calls/4.json
new file mode 100644
index 0000000..dbbb268
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/calls/4.json
@@ -0,0 +1 @@
+{"v":1,"id":4,"method":"GET","uri":"wss://api.example.com/live","source":"io","state":"completed","startedAt":"2026-10-20T09:30:11.000Z","firstByteAt":null,"endedAt":"2026-10-20T09:30:15.000Z","request":{"headers":[],"body":{"total":0,"kept":0,"type":null}},"response":{"status":101,"reason":"Switching Protocols","headers":[["upgrade","websocket"]],"body":{"total":0,"kept":0,"type":null},"remoteAddress":null,"remotePort":null,"certificate":null,"redirects":[],"compression":null},"error":null,"graphql":null,"pinned":false,"messages":{"kind":"webSocket","total":3,"bytes":27}}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/calls/5.json b/packages/flutwire/test/fixtures/history_0_2_0/calls/5.json
new file mode 100644
index 0000000..9dc3508
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/calls/5.json
@@ -0,0 +1 @@
+{"v":1,"id":5,"method":"POST","uri":"https://api.example.com/v1/orders","source":"io","state":"completed","startedAt":"2026-10-20T09:30:20.000Z","firstByteAt":null,"endedAt":"2026-10-20T09:30:20.080Z","request":{"headers":[["content-type","application/json"]],"body":{"total":34,"kept":34,"type":"application/json"}},"response":{"status":201,"reason":"Created","headers":[["content-type","application/json"]],"body":{"total":24,"kept":24,"type":"application/json"},"remoteAddress":null,"remotePort":null,"certificate":null,"redirects":[],"compression":null},"error":null,"graphql":null,"pinned":false,"resentFrom":1,"intercept":"edited"}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/calls/6.json b/packages/flutwire/test/fixtures/history_0_2_0/calls/6.json
new file mode 100644
index 0000000..84bd980
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/calls/6.json
@@ -0,0 +1 @@
+{"v":1,"id":6,"method":"GET","uri":"https://api.example.com/v1/health","source":"io","state":"completed","startedAt":"2026-10-20T09:30:30.000Z","firstByteAt":"2026-10-20T09:30:30.040Z","endedAt":"2026-10-20T09:30:30.052Z","request":{"headers":[],"body":{"total":0,"kept":0,"type":null}},"response":{"status":200,"reason":"OK","headers":[["content-type","text/plain"]],"body":{"total":2,"kept":2,"type":"text/plain"},"remoteAddress":"203.0.113.7","remotePort":443,"certificate":null,"redirects":[],"compression":null},"error":null,"graphql":null,"pinned":false}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/messages/4.jsonl b/packages/flutwire/test/fixtures/history_0_2_0/messages/4.jsonl
new file mode 100644
index 0000000..6643603
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/messages/4.jsonl
@@ -0,0 +1,3 @@
+{"index":0,"sent":true,"at":"2026-10-20T09:30:12.000Z","kind":"text","data":"eyJoZWxsbyI6InNlcnZlciJ9","total":18}
+{"index":1,"sent":false,"at":"2026-10-20T09:30:13.000Z","kind":"binary","data":"AQIDBA==","total":4}
+{"index":2,"sent":false,"at":"2026-10-20T09:30:14.000Z","kind":"close","data":"A+hieWU=","total":5}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/history_0_2_0/preferences.json b/packages/flutwire/test/fixtures/history_0_2_0/preferences.json
new file mode 100644
index 0000000..d1b485d
--- /dev/null
+++ b/packages/flutwire/test/fixtures/history_0_2_0/preferences.json
@@ -0,0 +1 @@
+{"themeMode":"dark","dockSide":"right"}
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/protoc/check.desc b/packages/flutwire/test/fixtures/protoc/check.desc
new file mode 100644
index 0000000..2ccd731
Binary files /dev/null and b/packages/flutwire/test/fixtures/protoc/check.desc differ
diff --git a/packages/flutwire/test/fixtures/protoc/check.proto b/packages/flutwire/test/fixtures/protoc/check.proto
new file mode 100644
index 0000000..71674ee
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/check.proto
@@ -0,0 +1,83 @@
+syntax = "proto3";
+
+package check.v1;
+
+import "google/protobuf/duration.proto";
+import "google/protobuf/timestamp.proto";
+
+enum Level {
+ LEVEL_UNSPECIFIED = 0;
+ LEVEL_LOW = 1;
+ LEVEL_HIGH = 2;
+ LEVEL_NEGATIVE = -1;
+}
+
+message Scalars {
+ double d = 1;
+ float f = 2;
+ int32 i32 = 3;
+ int64 i64 = 4;
+ uint32 u32 = 5;
+ uint64 u64 = 6;
+ sint32 s32 = 7;
+ sint64 s64 = 8;
+ fixed32 fx32 = 9;
+ fixed64 fx64 = 10;
+ sfixed32 sf32 = 11;
+ sfixed64 sf64 = 12;
+ bool b = 13;
+ string s = 14;
+ bytes raw = 15;
+}
+
+message Address {
+ string city = 1;
+ string street = 2;
+}
+
+message Club {
+ enum Kind {
+ KIND_UNKNOWN = 0;
+ KIND_SOCIAL = 1;
+ KIND_TEAM = 2;
+ }
+
+ message Member {
+ string handle = 1;
+ bool admin = 2;
+ }
+
+ string name = 1;
+ Level level = 2;
+ repeated string tags = 3;
+ repeated int32 scores = 4;
+ repeated double ratios = 5;
+ map counts = 6;
+ map members = 7;
+ Address address = 8;
+ repeated Address branches = 9;
+ oneof contact {
+ string email = 10;
+ int64 phone = 11;
+ Address office = 12;
+ }
+ optional int32 rank = 13;
+ google.protobuf.Timestamp founded = 14;
+ google.protobuf.Duration season = 15;
+ repeated Level levels = 16;
+ Kind kind = 17;
+ Scalars scalars = 18;
+}
+
+message GetClubRequest {
+ string name = 1;
+}
+
+message ListClubsRequest {
+ int32 limit = 1;
+}
+
+service Clubs {
+ rpc Get(GetClubRequest) returns (Club);
+ rpc List(ListClubsRequest) returns (stream Club);
+}
diff --git a/packages/flutwire/test/fixtures/protoc/club.bin b/packages/flutwire/test/fixtures/protoc/club.bin
new file mode 100644
index 0000000..7f867f6
Binary files /dev/null and b/packages/flutwire/test/fixtures/protoc/club.bin differ
diff --git a/packages/flutwire/test/fixtures/protoc/club_office.bin b/packages/flutwire/test/fixtures/protoc/club_office.bin
new file mode 100644
index 0000000..b28279e
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/club_office.bin
@@ -0,0 +1,3 @@
+
+Officeb
+Leeds
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/protoc/club_phone.bin b/packages/flutwire/test/fixtures/protoc/club_phone.bin
new file mode 100644
index 0000000..33a3bab
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/club_phone.bin
@@ -0,0 +1,2 @@
+
+PhoneX õôïî
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/protoc/club_unknown.bin b/packages/flutwire/test/fixtures/protoc/club_unknown.bin
new file mode 100644
index 0000000..8f07a86
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/club_unknown.bin
@@ -0,0 +1,2 @@
+
+Hikers˜
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/protoc/get_request.bin b/packages/flutwire/test/fixtures/protoc/get_request.bin
new file mode 100644
index 0000000..e175488
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/get_request.bin
@@ -0,0 +1,2 @@
+
+Hikers
\ No newline at end of file
diff --git a/packages/flutwire/test/fixtures/protoc/legacy.proto b/packages/flutwire/test/fixtures/protoc/legacy.proto
new file mode 100644
index 0000000..fd2f55f
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/legacy.proto
@@ -0,0 +1,11 @@
+syntax = "proto2";
+
+package check.legacy;
+
+message Old {
+ required string name = 1;
+ optional int32 age = 2 [default = 7];
+ repeated int32 loose = 3;
+ repeated int32 tight = 4 [packed = true];
+ optional bytes blob = 5;
+}
diff --git a/packages/flutwire/test/fixtures/protoc/make.py b/packages/flutwire/test/fixtures/protoc/make.py
new file mode 100644
index 0000000..d3ad8bf
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/make.py
@@ -0,0 +1,81 @@
+"""Writes the fixtures next to it with protoc and its Python runtime:
+the descriptor set, and messages serialised by the code protoc generates.
+
+ python3 -m venv venv && venv/bin/pip install grpcio-tools
+ venv/bin/python make.py
+"""
+import importlib
+import importlib.resources
+import os
+import sys
+import tempfile
+
+from google.protobuf import descriptor_pb2, duration_pb2, timestamp_pb2
+from grpc_tools import protoc
+
+here = os.path.dirname(os.path.abspath(__file__))
+well_known = str(importlib.resources.files('grpc_tools') / '_proto')
+
+with tempfile.TemporaryDirectory() as generated:
+ status = protoc.main([
+ 'protoc', f'-I{here}', f'-I{well_known}', f'--python_out={generated}',
+ f'--descriptor_set_out={here}/check.desc', '--include_imports',
+ f'{here}/check.proto', f'{here}/legacy.proto',
+ ])
+ if status != 0:
+ sys.exit(status)
+ sys.path.insert(0, generated)
+ check = importlib.import_module('check_pb2')
+ legacy = importlib.import_module('legacy_pb2')
+
+
+def write(name, message, extra=b''):
+ with open(f'{here}/{name}.bin', 'wb') as out:
+ # Sorted map entries, so that a run of this script writes the same bytes.
+ out.write(message.SerializeToString(deterministic=True) + extra)
+
+
+write('scalars', check.Scalars(
+ d=3.141592653589793, f=0.1, i32=-2147483648, i64=-9223372036854775808,
+ u32=4294967295, u64=18446744073709551615, s32=-1, s64=-9007199254740993,
+ fx32=4294967295, fx64=18446744073709551615, sf32=-5, sf64=-6, b=True,
+ s='héllo ☃ \U0001F600', raw=b'\x01\x02\xff',
+))
+
+club = check.Club(
+ name='Hikers',
+ level=check.LEVEL_HIGH,
+ tags=['outdoor', 'weekend'],
+ scores=[3, 5, 8, -1],
+ ratios=[0.5, 1.25],
+ counts={'x': 1, 'y': 2},
+ members={
+ 1: check.Club.Member(handle='ada', admin=True),
+ 2: check.Club.Member(handle='bob'),
+ },
+ address=check.Address(city='London', street="St James's Square"),
+ branches=[check.Address(city='Bath'), check.Address(city='Leeds')],
+ email='club@example.com',
+ rank=0,
+ founded=timestamp_pb2.Timestamp(seconds=1700000000, nanos=500000000),
+ season=duration_pb2.Duration(seconds=7776000, nanos=1500000),
+ levels=[check.LEVEL_LOW, check.LEVEL_NEGATIVE, check.LEVEL_HIGH],
+ kind=check.Club.KIND_TEAM,
+ scalars=check.Scalars(i32=7, s='nested'),
+)
+write('club', club)
+
+by_phone = check.Club(name='Phone', phone=442079460000)
+write('club_phone', by_phone)
+
+by_office = check.Club(name='Office', office=check.Address(city='Leeds'))
+write('club_office', by_office)
+
+# A field the schema does not know, as an older client reading a newer server
+# would meet one: number 99, a varint of 5.
+write('club_unknown', check.Club(name='Hikers'), extra=b'\x98\x06\x05')
+
+write('get_request', check.GetClubRequest(name='Hikers'))
+
+write('old', legacy.Old(name='Ada', loose=[1, 2, 3], tight=[4, 5, 6],
+ blob=b'\x00\x01'))
diff --git a/packages/flutwire/test/fixtures/protoc/old.bin b/packages/flutwire/test/fixtures/protoc/old.bin
new file mode 100644
index 0000000..5afdc7e
Binary files /dev/null and b/packages/flutwire/test/fixtures/protoc/old.bin differ
diff --git a/packages/flutwire/test/fixtures/protoc/scalars.bin b/packages/flutwire/test/fixtures/protoc/scalars.bin
new file mode 100644
index 0000000..c73869e
--- /dev/null
+++ b/packages/flutwire/test/fixtures/protoc/scalars.bin
@@ -0,0 +1 @@
+ -DTû! @ÍÌÌ=€€€€øÿÿÿÿ €€€€€€€€€(ÿÿÿÿ0ÿÿÿÿÿÿÿÿÿ8@�€€€€€€ MÿÿÿÿQÿÿÿÿÿÿÿÿ]ûÿÿÿaúÿÿÿÿÿÿÿhrhéllo ☃ 😀zÿ
\ No newline at end of file
diff --git a/packages/flutwire/test/share_test.dart b/packages/flutwire/test/share_test.dart
index 53d09d6..e018735 100644
--- a/packages/flutwire/test/share_test.dart
+++ b/packages/flutwire/test/share_test.dart
@@ -11,7 +11,7 @@ import 'ui/calls.dart';
const _channel = MethodChannel('flutwire.test/share');
/// A store that hands out the full call, as one kept on disk does.
-class _FullStore extends MemoryFlutwireStore {
+final class _FullStore extends MemoryFlutwireStore {
_FullStore(this.full);
final FlutwireCall full;
diff --git a/packages/flutwire/test/store/disk_store_test.dart b/packages/flutwire/test/store/disk_store_test.dart
index dc2e535..df53815 100644
--- a/packages/flutwire/test/store/disk_store_test.dart
+++ b/packages/flutwire/test/store/disk_store_test.dart
@@ -7,6 +7,7 @@ import 'package:flutwire/src/config.dart';
import 'package:flutwire/src/model/body.dart';
import 'package:flutwire/src/model/call.dart';
import 'package:flutwire/src/model/headers.dart';
+import 'package:flutwire/src/model/message.dart';
import 'package:flutwire/src/store/disk_store_io.dart';
import 'package:flutwire/src/store/open_store.dart';
import 'package:flutwire/src/store/store.dart';
@@ -265,4 +266,125 @@ void main() {
expect(store, isNot(isA()));
await store.dispose();
});
+
+ group('the layout of the folder', () {
+ File marker() => File('${root.path}/layout');
+
+ void copyFixture(String name) {
+ final from = Directory('test/fixtures/$name').absolute;
+ for (final entity in from.listSync(recursive: true)) {
+ final relative = entity.path.substring(from.path.length);
+ if (entity is Directory) {
+ Directory('${root.path}$relative').createSync(recursive: true);
+ } else if (entity is File) {
+ File('${root.path}$relative')
+ ..createSync(recursive: true)
+ ..writeAsBytesSync(entity.readAsBytesSync());
+ }
+ }
+ }
+
+ Map snapshot() => {
+ for (final file in root.listSync(recursive: true).whereType())
+ file.path.substring(root.path.length): base64Encode(
+ file.readAsBytesSync(),
+ ),
+ };
+
+ test('a new folder records its layout and keeps it', () async {
+ var store = await open();
+ store.put(call(1));
+ await store.settled;
+ await store.dispose();
+ expect(marker().readAsStringSync().trim(), '1');
+ store = await open();
+ store.flush();
+ expect(ids(store), [1]);
+ await store.dispose();
+ });
+
+ test('a history written by 0.2.0 is read and gets its layout', () async {
+ copyFixture('history_0_2_0');
+ expect(marker().existsSync(), isFalse);
+ final store = await open();
+ store.flush();
+ expect(ids(store), [6, 5, 4, 3, 2, 1]);
+ expect(marker().readAsStringSync().trim(), '1');
+ expect(store.preferences.read('themeMode'), 'dark');
+ expect(store.preferences.read('dockSide'), 'right');
+
+ final order = (await store.load(1))!;
+ expect(order.pinned, isTrue);
+ expect(order.request.body.text, '{"items":[{"sku":"A-12","qty":2}]}');
+ expect(order.response!.statusCode, 201);
+ expect(order.response!.body.text, '{"id":"o-77","total":42}');
+ expect(order.response!.remoteAddress, '203.0.113.7');
+
+ final club = (await store.load(2))!;
+ expect(club.source, FlutwireSource.dio);
+ expect(club.response!.body.text, 'no such club');
+ expect(club.response!.compression, 'gzip');
+ expect(club.response!.redirects.single.statusCode, 301);
+ expect(club.response!.certificate!.subject, 'CN=api.example.com');
+
+ final failed = (await store.load(3))!;
+ expect(failed.state, FlutwireCallState.failed);
+ expect(failed.error!.message, 'Connection reset by peer');
+ expect(failed.graphql!.operationName, 'Clubs');
+ expect(failed.request.body.text, contains('query Clubs'));
+
+ final socket = (await store.load(4))!;
+ expect(socket.messages!.kind, FlutwireConversationKind.webSocket);
+ expect(socket.messages!.total, 3);
+ expect(socket.messages!.items.map((m) => m.kind), [
+ FlutwireMessageKind.text,
+ FlutwireMessageKind.binary,
+ FlutwireMessageKind.close,
+ ]);
+ expect(socket.messages!.items.first.text, '{"hello":"server"}');
+ expect(socket.messages!.items.first.sent, isTrue);
+
+ final again = (await store.load(5))!;
+ expect(again.resentFrom, 1);
+ expect(again.intercept, FlutwireIntercept.edited);
+
+ // A summary from 0.1.0 has no field for any of the above.
+ final health = (await store.load(6))!;
+ expect(health.response!.statusCode, 200);
+ expect(health.response!.body.text, 'ok');
+ expect(health.resentFrom, isNull);
+ expect(health.intercept, isNull);
+ expect(health.messages, isNull);
+ await store.dispose();
+ });
+
+ test('a folder of a newer layout is left as it is', () async {
+ copyFixture('history_0_2_0');
+ marker().writeAsStringSync('2\n');
+ File('${root.path}/calls/7.json').writeAsStringSync('{"v":2,"x":[]}');
+ File('${root.path}/bodies/7.res').writeAsStringSync('from the future');
+ final before = snapshot();
+
+ await expectLater(open(), throwsFormatException);
+ expect(snapshot(), before);
+
+ final store = await openStore(
+ FlutwireConfig(enabled: true, storagePath: root.path),
+ );
+ expect(store, isNot(isA()));
+ expect(ids(store as MemoryFlutwireStore), isEmpty);
+ await store.dispose();
+ expect(snapshot(), before);
+ });
+
+ for (final text in ['', 'two', '0', '-1', '1.5']) {
+ test('a layout of "$text" is left as it is', () async {
+ copyFixture('history_0_2_0');
+ marker().writeAsStringSync(text);
+ final before = snapshot();
+ await expectLater(open(), throwsFormatException);
+ expect(snapshot(), before);
+ });
+ }
+ });
}
diff --git a/packages/flutwire/test/ui/dock_layout_test.dart b/packages/flutwire/test/ui/dock_layout_test.dart
index bb52828..ad58e5c 100644
--- a/packages/flutwire/test/ui/dock_layout_test.dart
+++ b/packages/flutwire/test/ui/dock_layout_test.dart
@@ -198,6 +198,32 @@ void main() {
expect(find.text('app 680x800'), findsOneWidget);
});
+ testWidgets('does not offer the right side on a screen too narrow for it', (
+ tester,
+ ) async {
+ final session = await pumpDock(tester);
+ await openDock(tester, session);
+
+ await tester.tap(find.byIcon(Icons.more_vert_rounded));
+ await settle(tester);
+
+ expect(find.text('Open full screen'), findsOneWidget);
+ expect(find.text('Move to the right'), findsNothing);
+ });
+
+ testWidgets('keeps the panel under the app of a narrow screen', (
+ tester,
+ ) async {
+ final session = FakeSession(calls: [makeCall(1)]);
+ await session.store.preferences.write('dockSide', 'right');
+ await pumpDock(tester, session: session);
+
+ await openDock(tester, session);
+
+ expect(find.text('app 420x506'), findsOneWidget);
+ expect(tester.getTopLeft(find.text(_inspector)).dy, greaterThan(506));
+ });
+
testWidgets('closes with the button in its top bar', (tester) async {
final session = await pumpDock(tester, size: _wide);
await openDock(tester, session);
diff --git a/packages/flutwire/test/ui/inspector_page_test.dart b/packages/flutwire/test/ui/inspector_page_test.dart
index 328610f..181e20c 100644
--- a/packages/flutwire/test/ui/inspector_page_test.dart
+++ b/packages/flutwire/test/ui/inspector_page_test.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart' show RenderParagraph;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutwire/flutwire.dart';
import 'package:flutwire/src/ui/compare/compare_page.dart';
@@ -296,6 +297,26 @@ void main() {
);
});
+ testWidgets('the count is not cut short on a phone', (tester) async {
+ await open(tester, size: const Size(390, 800));
+ await tester.longPress(find.text('/login'));
+ await settle(tester);
+ await tester.tap(find.text('/items/9'));
+ await settle(tester);
+
+ // Five buttons and a close leave the title little room.
+ final title = find.text('2 selected');
+ expect(title, findsOneWidget);
+ expect(
+ tester.renderObject(title).didExceedMaxLines,
+ isFalse,
+ );
+ expect(
+ tester.getBottomRight(title).dx,
+ lessThan(tester.getTopLeft(find.byTooltip('Select all')).dx),
+ );
+ });
+
testWidgets('pinning keeps the call pinned in the history', (tester) async {
final session = await open(tester);
await tester.longPress(find.text('/login'));
diff --git a/packages/flutwire/test/ui/metric_strip_test.dart b/packages/flutwire/test/ui/metric_strip_test.dart
new file mode 100644
index 0000000..edf1cd2
--- /dev/null
+++ b/packages/flutwire/test/ui/metric_strip_test.dart
@@ -0,0 +1,59 @@
+import 'package:flutter/painting.dart' show Size;
+import 'package:flutter_test/flutter_test.dart';
+import 'package:flutwire/src/ui/widgets/metric_strip.dart';
+
+import 'calls.dart';
+import 'harness.dart';
+
+const _labels = ['CALLS', 'FAILED', 'AVERAGE', 'TRANSFERRED'];
+
+void main() {
+ testWidgets('puts the four numbers in a row when they fit', (tester) async {
+ // The test font is wider than the one the inspector ships with.
+ await pumpFlutwire(
+ tester,
+ MetricStrip(calls: [makeCall(1)]),
+ size: const Size(700, 600),
+ );
+
+ final tops = [for (final l in _labels) tester.getTopLeft(find.text(l)).dy];
+ expect(tops.toSet(), hasLength(1));
+ });
+
+ testWidgets('puts them in two rows of two when they do not', (tester) async {
+ // The width of the inspector in a panel of the least size.
+ await pumpFlutwire(
+ tester,
+ MetricStrip(calls: [makeCall(1)]),
+ size: const Size(280, 600),
+ );
+
+ final calls = tester.getTopLeft(find.text('CALLS'));
+ final failed = tester.getTopLeft(find.text('FAILED'));
+ final average = tester.getTopLeft(find.text('AVERAGE'));
+ final transferred = tester.getTopLeft(find.text('TRANSFERRED'));
+ expect(failed.dy, calls.dy);
+ expect(transferred.dy, average.dy);
+ expect(average.dy, greaterThan(calls.dy));
+ expect(failed.dx, greaterThan(calls.dx));
+ expect(transferred.dx, greaterThan(average.dx));
+ });
+
+ testWidgets('never breaks a label into lines', (tester) async {
+ for (final width in [280.0, 340.0, 420.0, 520.0, 700.0]) {
+ await pumpFlutwire(
+ tester,
+ MetricStrip(calls: [makeCall(1)]),
+ size: Size(width, 600),
+ );
+
+ for (final label in _labels) {
+ expect(
+ tester.getSize(find.text(label)).height,
+ lessThan(20),
+ reason: '$label at $width',
+ );
+ }
+ }
+ });
+}
diff --git a/packages/flutwire_cloud_functions/CHANGELOG.md b/packages/flutwire_cloud_functions/CHANGELOG.md
index 4271f0d..4e4801a 100644
--- a/packages/flutwire_cloud_functions/CHANGELOG.md
+++ b/packages/flutwire_cloud_functions/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 1.0.0
+
+Needs `flutwire` 1.0.0. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#100---2026-10-24) lists what the other packages gained.
+
## 0.2.0
Adds an example, and needs `flutwire` 0.2.0. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#020---2026-10-24) lists what the other packages gained.
diff --git a/packages/flutwire_cloud_functions/pubspec.yaml b/packages/flutwire_cloud_functions/pubspec.yaml
index 6d7ca37..bee26da 100644
--- a/packages/flutwire_cloud_functions/pubspec.yaml
+++ b/packages/flutwire_cloud_functions/pubspec.yaml
@@ -2,7 +2,7 @@ name: flutwire_cloud_functions
description: >-
Records the Firebase Cloud Functions an app calls in Flutwire. They go through
the native Firebase SDK, so the dart:io capture never sees them.
-version: 0.2.0
+version: 1.0.0
repository: https://github.com/NaCode-Studios/Flutwire
issue_tracker: https://github.com/NaCode-Studios/Flutwire/issues
funding:
@@ -26,7 +26,7 @@ dependencies:
firebase_core: ">=4.0.0 <6.0.0"
flutter:
sdk: flutter
- flutwire: ^0.2.0
+ flutwire: ^1.0.0
dev_dependencies:
cloud_functions: ">=6.0.0 <8.0.0"
diff --git a/packages/flutwire_dio/CHANGELOG.md b/packages/flutwire_dio/CHANGELOG.md
index a735ff1..2a8bf7a 100644
--- a/packages/flutwire_dio/CHANGELOG.md
+++ b/packages/flutwire_dio/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 1.0.0
+
+Needs `flutwire` 1.0.0, and exports `FlutwireDioInterceptor` by name. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#100---2026-10-24) lists what the other packages gained.
+
## 0.2.0
Needs `dio` 5.2 or later, the first release that has `DioException`: 0.1.0 allowed 5.0 and 5.1 and did not compile against them. When the interceptor stands down for the `dart:io` capture it now tells Flutwire, so that Flutwire can say above the list when the capture never recorded the call. Adds an example, and needs `flutwire` 0.2.0. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#020---2026-10-24) lists what the other packages gained.
diff --git a/packages/flutwire_dio/lib/flutwire_dio.dart b/packages/flutwire_dio/lib/flutwire_dio.dart
index 39ac53d..647c121 100644
--- a/packages/flutwire_dio/lib/flutwire_dio.dart
+++ b/packages/flutwire_dio/lib/flutwire_dio.dart
@@ -6,4 +6,4 @@
/// own.
library;
-export 'src/interceptor.dart';
+export 'src/interceptor.dart' show FlutwireDioInterceptor;
diff --git a/packages/flutwire_dio/pubspec.yaml b/packages/flutwire_dio/pubspec.yaml
index 7ba8ff2..ac81917 100644
--- a/packages/flutwire_dio/pubspec.yaml
+++ b/packages/flutwire_dio/pubspec.yaml
@@ -2,7 +2,7 @@ name: flutwire_dio
description: >-
A Dio interceptor for Flutwire. It records the calls that the dart:io capture
cannot see, such as every call on the web.
-version: 0.2.0
+version: 1.0.0
repository: https://github.com/NaCode-Studios/Flutwire
issue_tracker: https://github.com/NaCode-Studios/Flutwire/issues
funding:
@@ -24,7 +24,7 @@ dependencies:
dio: ^5.2.0
flutter:
sdk: flutter
- flutwire: ^0.2.0
+ flutwire: ^1.0.0
dev_dependencies:
flutter_lints: ^6.0.0
diff --git a/packages/flutwire_http/CHANGELOG.md b/packages/flutwire_http/CHANGELOG.md
index e1b1434..71e6319 100644
--- a/packages/flutwire_http/CHANGELOG.md
+++ b/packages/flutwire_http/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 1.0.0
+
+`FlutwireClient` is final: it wraps another client and was not meant to be extended. Needs `flutwire` 1.0.0. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#100---2026-10-24) lists what the other packages gained.
+
## 0.2.0
When the client stands down for the `dart:io` capture it now tells Flutwire, so that Flutwire can say above the list when the capture never recorded the call, as happens with an inner client that does not use `dart:io`. Adds an example, and needs `flutwire` 0.2.0. The [repository changelog](https://github.com/NaCode-Studios/Flutwire/blob/main/CHANGELOG.md#020---2026-10-24) lists what the other packages gained.
diff --git a/packages/flutwire_http/lib/flutwire_http.dart b/packages/flutwire_http/lib/flutwire_http.dart
index b4ac1fb..ad5e74c 100644
--- a/packages/flutwire_http/lib/flutwire_http.dart
+++ b/packages/flutwire_http/lib/flutwire_http.dart
@@ -7,4 +7,4 @@
/// `ChopperClient(client: FlutwireClient(http.Client()))` covers it too.
library;
-export 'src/client.dart';
+export 'src/client.dart' show FlutwireClient;
diff --git a/packages/flutwire_http/lib/src/client.dart b/packages/flutwire_http/lib/src/client.dart
index e72b394..b9bbe4b 100644
--- a/packages/flutwire_http/lib/src/client.dart
+++ b/packages/flutwire_http/lib/src/client.dart
@@ -18,7 +18,7 @@ import 'package:http/http.dart';
/// A [MultipartRequest] is described by its fields and files rather than
/// reproduced byte for byte, because its body can only be read once, by the
/// client that sends it.
-class FlutwireClient extends BaseClient {
+final class FlutwireClient extends BaseClient {
/// Wraps [_inner]. Pass [force] to record even when the `dart:io` capture
/// is installed, for a client that does not go through `dart:io`.
FlutwireClient(this._inner, {this.force = false});
diff --git a/packages/flutwire_http/pubspec.yaml b/packages/flutwire_http/pubspec.yaml
index 991c93b..5c7dee5 100644
--- a/packages/flutwire_http/pubspec.yaml
+++ b/packages/flutwire_http/pubspec.yaml
@@ -2,7 +2,7 @@ name: flutwire_http
description: >-
A package:http client for Flutwire. It records the calls that the dart:io
capture cannot see, such as every call on the web.
-version: 0.2.0
+version: 1.0.0
repository: https://github.com/NaCode-Studios/Flutwire
issue_tracker: https://github.com/NaCode-Studios/Flutwire/issues
funding:
@@ -23,7 +23,7 @@ resolution: workspace
dependencies:
flutter:
sdk: flutter
- flutwire: ^0.2.0
+ flutwire: ^1.0.0
http: ^1.0.0
dev_dependencies: