Skip to main content

flutwire 1.0.0 copy "flutwire: ^1.0.0" to clipboard
flutwire: ^1.0.0 copied to clipboard

Network inspector for Flutter. Records the HTTP calls your app makes, keeps them across restarts, and opens them from a notification in the shade.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';

import 'package:dio/dio.dart';
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';
import 'package:flutwire_http/flutwire_http.dart';
import 'package:http/http.dart' as http;

/// The whole integration is the next line, and `interception` is the one
/// option it turns on, so that a mock or a breakpoint can be tried on the calls
/// below. Everything below only makes calls, so there is something to look at.
Future<void> main() async {
  await Flutwire.init(config: const FlutwireConfig(interception: true));
  runApp(const ExampleApp());
}

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutwire example',
      navigatorKey: Flutwire.navigatorKey,
      // Floats the inspector button where there is no notification to tap.
      builder: Flutwire.builder,
      debugShowCheckedModeBanner: false,
      themeMode: ThemeMode.system,
      theme: ThemeData(colorSchemeSeed: const Color(0xFF546E7A)),
      darkTheme: ThemeData(
        colorSchemeSeed: const Color(0xFF546E7A),
        brightness: Brightness.dark,
      ),
      home: const Home(),
    );
  }
}

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

  final String label;
  final Future<void> Function() run;
}

const _bin = 'https://httpbin.org';
const _todos = 'https://jsonplaceholder.typicode.com';

// On mobile and desktop the adapters below stand down, because Flutwire already
// records every dart:io call. On the web they are what records the calls.
final dio = Dio()..interceptors.add(FlutwireDioInterceptor());
final client = FlutwireClient(http.Client());

Future<void> _io(String method, String url, {Object? json}) async {
  if (kIsWeb) return _safe(() => client.get(Uri.parse(url)));
  final io = HttpClient();
  try {
    final request = await io.openUrl(method, Uri.parse(url));
    if (json != null) {
      request.headers.contentType = ContentType.json;
      request.write(jsonEncode(json));
    }
    final response = await request.close();
    await response.drain<void>();
  } on Object {
    // The inspector shows what went wrong.
  } finally {
    io.close();
  }
}

Future<void> _safe(Future<Object?> Function() call) async {
  try {
    await call();
  } on Object {
    // As above.
  }
}

final List<(String, List<Probe>)> groups = [
  (
    'dart:io, http and Dio',
    [
      Probe('dart:io GET', () => _io('GET', '$_todos/todos/1')),
      Probe(
        'http GET',
        () => _safe(() => client.get(Uri.parse('$_todos/users/1'))),
      ),
      Probe('Dio GET', () => _safe(() => dio.get<Object?>('$_todos/posts/1'))),
      Probe(
        'http POST JSON',
        () => _safe(
          () => client.post(
            Uri.parse('$_todos/posts'),
            headers: {
              'content-type': 'application/json',
              'authorization': 'Bearer example-token',
            },
            body: jsonEncode({
              'title': 'hello',
              'body': 'from Flutwire',
              'userId': 1,
            }),
          ),
        ),
      ),
      Probe(
        'Dio POST form',
        () => _safe(
          () => dio.post<Object?>(
            '$_bin/post',
            data: {'name': 'Ada', 'password': 'hunter2'},
            options: Options(contentType: Headers.formUrlEncodedContentType),
          ),
        ),
      ),
      Probe(
        'Dio multipart',
        () => _safe(
          () => dio.post<Object?>(
            '$_bin/post',
            data: FormData.fromMap({
              'caption': 'a small upload',
              'file': MultipartFile.fromBytes(
                List.generate(2048, (i) => i % 251),
                filename: 'bytes.bin',
              ),
            }),
          ),
        ),
      ),
      Probe(
        'GraphQL query',
        () => _safe(
          () => client.post(
            Uri.parse('https://countries.trevorblades.com/graphql'),
            headers: {'content-type': 'application/json'},
            body: jsonEncode({
              'operationName': 'Countries',
              'query': 'query Countries { countries { code name emoji } }',
            }),
          ),
        ),
      ),
    ],
  ),
  (
    'Bodies',
    [
      Probe('JSON', () => _safe(() => client.get(Uri.parse('$_bin/json')))),
      Probe('XML', () => _safe(() => client.get(Uri.parse('$_bin/xml')))),
      Probe('HTML', () => _safe(() => client.get(Uri.parse('$_bin/html')))),
      Probe(
        'Image (PNG)',
        () => _safe(() => client.get(Uri.parse('$_bin/image/png'))),
      ),
      Probe(
        'Binary',
        () => _safe(() => client.get(Uri.parse('$_bin/bytes/4096'))),
      ),
      Probe('Gzip', () => _safe(() => client.get(Uri.parse('$_bin/gzip')))),
      Probe(
        'Big JSON',
        () => _safe(() => client.get(Uri.parse('$_bin/stream/60'))),
      ),
      Probe(
        'Cookies',
        () => _safe(
          () =>
              client.get(Uri.parse('$_bin/cookies/set?theme=dark&session=abc')),
        ),
      ),
      Probe('MessagePack', () => _canned(_messagePack, 'application/msgpack')),
      Probe('Protobuf', () => _canned(_protobuf, 'application/x-protobuf')),
    ],
  ),
  (
    'Streams',
    [Probe('WebSocket', _webSocket), Probe('Event stream', _eventStream)],
  ),
  (
    'Isolates',
    [
      Probe('Flutwire.run', () => _safe(() => Flutwire.run(_runInAnIsolate))),
      Probe(
        'Flutwire.compute',
        () => _safe(() => Flutwire.compute(_fetchIn, '$_todos/todos/5')),
      ),
    ],
  ),
  (
    'Trouble',
    [
      Probe(
        'Redirects',
        () => _safe(() => client.get(Uri.parse('$_bin/redirect/2'))),
      ),
      Probe(
        '404',
        () => _safe(() => client.get(Uri.parse('$_bin/status/404'))),
      ),
      Probe(
        '500',
        () => _safe(() => client.get(Uri.parse('$_bin/status/500'))),
      ),
      Probe(
        'Slow (6 s)',
        () => _safe(() => client.get(Uri.parse('$_bin/delay/6'))),
      ),
      Probe('Unknown host', () => _io('GET', 'https://nonexistent.invalid/')),
      Probe(
        'Timeout',
        () => _safe(
          () => dio.get<Object?>(
            '$_bin/delay/8',
            options: Options(receiveTimeout: const Duration(seconds: 1)),
          ),
        ),
      ),
    ],
  ),
];

/// `{"club": "Orion", "members": 3, "tags": ["a", "b"], "founded": 1999.5}`.
const List<int> _messagePack = [
  0x84, //
  0xa4, 0x63, 0x6c, 0x75, 0x62, 0xa5, 0x4f, 0x72, 0x69, 0x6f, 0x6e,
  0xa7, 0x6d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x03,
  0xa4, 0x74, 0x61, 0x67, 0x73, 0x92, 0xa1, 0x61, 0xa1, 0x62,
  0xa7, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x65, 0x64,
  0xcb, 0x40, 0x9f, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00,
];

/// A message of field 1 as "Orion", field 2 as 3 and field 3 as a message of
/// its own that holds "Vega" in field 1.
const List<int> _protobuf = [
  0x0a, 0x05, 0x4f, 0x72, 0x69, 0x6f, 0x6e, //
  0x10, 0x03,
  0x1a, 0x06, 0x0a, 0x04, 0x56, 0x65, 0x67, 0x61,
];

/// Answers with [body] and the media type [type], so that the viewer has
/// something to decode. The client records the call itself.
Future<void> _canned(List<int> body, String type) => _safe(
  () => FlutwireClient(
    _Canned(body, type),
    force: true,
  ).get(Uri.parse('$_todos/club')),
);

/// Opens a WebSocket to an echo server, sends a text and a binary message, and
/// closes it, so the inspector has a conversation to list. `dart:io` makes one
/// client for every WebSocket the first time it opens one, so this works
/// because `Flutwire.init` ran before that.
Future<void> _webSocket() async {
  if (kIsWeb) return;
  try {
    final socket = await WebSocket.connect('wss://echo.websocket.org');
    final closed = Completer<void>();
    socket.listen(
      (_) {},
      onDone: closed.complete,
      onError: (Object _) => closed.complete(),
    );
    socket
      ..add('hello')
      ..add(jsonEncode({'subscribe': 'prices'}))
      ..add(Uint8List.fromList([1, 2, 3]));
    await Future<void>.delayed(const Duration(seconds: 2));
    await socket.close(1000, 'done');
    await closed.future.timeout(const Duration(seconds: 5));
  } on Object {
    // The inspector shows what went wrong.
  }
}

/// Reads a few chunks of a public event stream and walks away, so the
/// inspector lists the events that arrived and the call ends as cancelled.
Future<void> _eventStream() async {
  if (kIsWeb) return;
  final io = HttpClient();
  try {
    final request = await io.getUrl(
      Uri.parse('https://stream.wikimedia.org/v2/stream/recentchange'),
    );
    request.headers.set('accept', 'text/event-stream');
    final response = await request.close();
    await response
        .transform(utf8.decoder)
        .take(6)
        .drain<void>()
        .timeout(const Duration(seconds: 10));
  } on Object {
    // The inspector shows what went wrong.
  } finally {
    io.close(force: true);
  }
}

/// A call made in an isolate that `Flutwire.run` attached to Flutwire, so it is
/// in the history like any other.
Future<void> _runInAnIsolate() => _fetchIn('$_todos/todos/4');

/// The same through `Flutwire.compute`, which hands the callback one message.
Future<void> _fetchIn(String url) async {
  if (kIsWeb) return _safe(() => client.get(Uri.parse(url)));
  final io = HttpClient();
  try {
    final request = await io.getUrl(Uri.parse(url));
    await (await request.close()).drain<void>();
  } on Object {
    // The inspector shows what went wrong.
  } finally {
    io.close();
  }
}

/// What Flutwire cannot see, so that the inspector has something to say about
/// it. These are left out of "Run everything": replacing the capture stops the
/// recording of everything after it.
final List<Probe> blindSpots = [
  Probe(
    'Isolate, not attached',
    () => _safe(() => Isolate.run(_fromAnotherIsolate)),
  ),
  Probe(
    'Off the wire',
    () => _safe(
      () => FlutwireClient(_OffTheWire()).get(Uri.parse('$_todos/todos/3')),
    ),
  ),
  Probe('Replace the capture', () async {
    _kept ??= HttpOverrides.current;
    HttpOverrides.global = _Replacing();
  }),
  Probe('Restore the capture', () async => HttpOverrides.global = _kept),
];

HttpOverrides? _kept;

/// A call from an isolate that never attached to Flutwire. Overrides belong to
/// one isolate, so nothing records it, and the inspector says so.
Future<void> _fromAnotherIsolate() async {
  try {
    await FlutwireClient(http.Client()).get(Uri.parse('$_todos/todos/2'));
  } on Object {
    // The point is the note in the inspector, not the answer.
  }
}

/// A client that answers every request with the same body.
class _Canned extends http.BaseClient {
  _Canned(this.body, this.type);

  final List<int> body;
  final String type;

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async =>
      http.StreamedResponse(
        Stream.value(body),
        200,
        headers: {'content-type': type},
      );
}

/// A client with a transport of its own, as `cronet_http` has: it never
/// reaches dart:io, so the capture cannot see what it sends.
class _OffTheWire extends http.BaseClient {
  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) async =>
      http.StreamedResponse(
        Stream.value(utf8.encode('{"from":"a client that is off the wire"}')),
        200,
        headers: {'content-type': 'application/json'},
      );
}

/// Overrides that know nothing of Flutwire's, as an app's own do when it sets
/// them after `Flutwire.init` and does not chain to what was there.
class _Replacing extends HttpOverrides {}

class Home extends StatelessWidget {
  const Home({super.key});

  Future<void> _runAll() async {
    for (final group in groups) {
      for (final probe in group.$2) {
        unawaited(probe.run());
        await Future<void>.delayed(const Duration(milliseconds: 120));
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Scaffold(
      appBar: AppBar(title: const Text('Flutwire example')),
      // 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: [
          Text(
            'Make some calls, then open the inspector, or pull down the notification shade.',
            style: Theme.of(context).textTheme.bodyMedium,
          ),
          const SizedBox(height: 12),
          FilledButton.tonal(
            onPressed: _runAll,
            child: const Text('Run everything'),
          ),
          for (final group in groups) ...[
            const SizedBox(height: 20),
            Text(
              group.$1,
              style: Theme.of(
                context,
              ).textTheme.titleSmall?.copyWith(color: scheme.primary),
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                for (final probe in group.$2)
                  OutlinedButton(
                    onPressed: () => unawaited(probe.run()),
                    child: Text(probe.label),
                  ),
              ],
            ),
          ],
          const SizedBox(height: 20),
          Text(
            'Blind spots',
            style: Theme.of(
              context,
            ).textTheme.titleSmall?.copyWith(color: scheme.primary),
          ),
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              for (final probe in blindSpots)
                OutlinedButton(
                  onPressed: () => unawaited(probe.run()),
                  child: Text(probe.label),
                ),
            ],
          ),
        ],
      ),
    );
  }
}
1
likes
160
points
0
downloads
screenshot

Documentation

API reference

Publisher

verified publishernacodestudios.it

Weekly Downloads

Network inspector for Flutter. Records the HTTP calls your app makes, keeps them across restarts, and opens them from a notification in the shade.

Repository (GitHub)
View/report issues
Contributing

Topics

#http #networking #debugging #inspector #notifications

Funding

Consider supporting this project:

github.com

License

Apache-2.0 (license)

Dependencies

flutter

More

Packages that depend on flutwire

Packages that implement flutwire