Skip to main content

peek 2.1.1 copy "peek: ^2.1.1" to clipboard
peek: ^2.1.1 copied to clipboard

A beautiful in-app network inspector for Flutter: records the calls your client makes and shows them — searchable, filterable, exportable.

example/lib/main.dart

import 'dart:async';
import 'dart:ui' show PlatformDispatcher;

import 'package:flutter/material.dart';
import 'package:peek/peek.dart';

import 'chopper_scenarios.dart';
import 'clients.dart';
import 'demo_data.dart';
import 'http_scenarios.dart';
import 'remote_setup.dart';
import 'scenarios.dart';
import 'share.dart';
import 'shop/shop_api.dart';
import 'shop/shop_app.dart';
import 'web_demo.dart';

void main() {
  // The web page embeds two views of one program, the app and Peek beside
  // it, so there is no implicit view to run in.
  if (PlatformDispatcher.instance.implicitView == null) {
    runWidget(const _WebDemo());
  } else {
    runApp(const PeekExampleApp());
  }
}

/// Makes real calls, four ways, so Peek has something real to show.
class PeekExampleApp extends StatefulWidget {
  /// Creates the example app.
  const PeekExampleApp({super.key});

  @override
  State<PeekExampleApp> createState() => _PeekExampleAppState();
}

class _PeekExampleAppState extends State<PeekExampleApp> {
  final Peek _peek = Peek();
  late final ExampleClients _clients = ExampleClients(_peek);
  late final PeekShareDelegate? _share = buildShareDelegate();
  ThemeMode _mode = ThemeMode.system;

  @override
  void initState() {
    super.initState();
    startRemote(_peek);
  }

  @override
  void dispose() {
    _clients.dispose();
    _peek.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => _app(
    title: 'Peek example',
    mode: _mode,
    home: _Home(
      peek: _peek,
      clients: _clients,
      share: _share,
      onModeChanged: (mode) => setState(() => _mode = mode),
    ),
  );
}

/// The example on its web page: an app on one phone and Peek on the
/// other, over one instance, so a call made on the left shows up on the
/// right. The left phone runs the example or the shop, as the page asks.
class _WebDemo extends StatefulWidget {
  const _WebDemo();

  @override
  State<_WebDemo> createState() => _WebDemoState();
}

class _WebDemoState extends State<_WebDemo> {
  // No remote: a browser tab cannot reach Peek Pro.
  final Peek _peek = Peek();
  late final ExampleClients _clients = ExampleClients(_peek);
  late final ShopApi _shop = ShopApi(buildShopClient(_peek));
  ThemeMode _mode = DemoTheme.current;

  @override
  void initState() {
    super.initState();
    DemoTheme.listen((mode) => setState(() => _mode = mode));
  }

  @override
  void dispose() {
    _clients.dispose();
    _shop.dio.close();
    _peek.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => DemoViews(
    builder:
        (phone) => _app(
          mode: _mode,
          builder: (context, child) => PhoneScreen(child: child!),
          theme: phone == DemoPhone.shop ? shopTheme(Brightness.light) : null,
          darkTheme:
              phone == DemoPhone.shop ? shopTheme(Brightness.dark) : null,
          home: switch (phone) {
            DemoPhone.example => _Home(
              peek: _peek,
              clients: _clients,
              share: null,
              onModeChanged: null,
            ),
            DemoPhone.shop => ShopHome(api: _shop),
            DemoPhone.peek => PeekScreen(peek: _peek),
          },
        ),
  );
}

/// An app around [home], in the example's colours unless given others.
///
/// Without a [title] the browser tab keeps the one its page gave it.
Widget _app({
  required ThemeMode mode,
  required Widget home,
  String? title,
  TransitionBuilder? builder,
  ThemeData? theme,
  ThemeData? darkTheme,
}) => MaterialApp(
  title: title,
  debugShowCheckedModeBanner: false,
  themeMode: mode,
  theme: theme ?? _Palette.light.themeData(Brightness.light),
  darkTheme: darkTheme ?? _Palette.dark.themeData(Brightness.dark),
  builder: builder,
  home: home,
);

/// The example's own colours: green ink on paper, or on near-black.
///
/// Peek takes only the brightness from here; everything the example draws
/// itself comes from this.
enum _Palette {
  light(
    background: Color(0xFFFFFFFF),
    ink: Color(0xFF141A16),
    secondary: Color(0xFF78827B),
    chipInk: Color(0xFF455048),
    fill: Color(0xFFF1F4F1),
    hairline: Color(0xFFECEFEC),
    border: Color(0xFFD4DCD6),
    accent: Color(0xFF1F7A3F),
    onAccent: Color(0xFFFFFFFF),
    badge: Color(0x3DFFFFFF),
    warning: Color(0xFF8A4B00),
    danger: Color(0xFFB3261E),
  ),
  dark(
    background: Color(0xFF0F1411),
    ink: Color(0xFFF2F5F2),
    secondary: Color(0xFF7F8A83),
    chipInk: Color(0xFFC1C8C3),
    fill: Color(0xFF232B26),
    hairline: Color(0xFF222624),
    border: Color(0xFF2E3832),
    accent: Color(0xFF5FCB84),
    onAccent: Color(0xFF0F1411),
    badge: Color(0xFF0F1411),
    warning: Color(0xFFFFB454),
    danger: Color(0xFFF2857C),
  );

  const _Palette({
    required this.background,
    required this.ink,
    required this.secondary,
    required this.chipInk,
    required this.fill,
    required this.hairline,
    required this.border,
    required this.accent,
    required this.onAccent,
    required this.badge,
    required this.warning,
    required this.danger,
  });

  final Color background;
  final Color ink;
  final Color secondary;
  final Color chipInk;
  final Color fill;
  final Color hairline;
  final Color border;
  final Color accent;
  final Color onAccent;
  final Color badge;
  final Color warning;
  final Color danger;

  static const List<String> _monoFallback = [
    'Menlo',
    'Consolas',
    'Roboto Mono',
    'monospace',
  ];

  TextStyle get title => TextStyle(
    fontSize: 28,
    fontWeight: FontWeight.w700,
    letterSpacing: -0.6,
    height: 1.2,
    color: ink,
  );

  TextStyle get lead => TextStyle(fontSize: 15, height: 1.45, color: secondary);

  TextStyle get caption => TextStyle(
    fontSize: 11,
    fontWeight: FontWeight.w600,
    letterSpacing: 1.1,
    color: secondary,
  );

  TextStyle get rowTitle => TextStyle(
    fontSize: 16,
    fontWeight: FontWeight.w500,
    height: 1.3,
    color: ink,
  );

  TextStyle get rowDetail =>
      TextStyle(fontSize: 13, height: 1.35, color: secondary);

  TextStyle get button =>
      TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: ink);

  TextStyle mono(double size, {Color? color, FontWeight? weight}) => TextStyle(
    fontFamily: 'monospace',
    fontFamilyFallback: _monoFallback,
    fontSize: size,
    fontWeight: weight,
    height: 1.2,
    color: color ?? ink,
  );

  ThemeData themeData(Brightness brightness) => ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: accent,
      brightness: brightness,
      surface: background,
      onSurface: ink,
    ),
    scaffoldBackgroundColor: background,
  );

  /// The colour a call's outcome reads in.
  Color outcome(PeekEntry entry) => switch (entry.statusClass) {
    PeekStatusClass.success ||
    PeekStatusClass.informational ||
    PeekStatusClass.redirect => accent,
    PeekStatusClass.clientError => warning,
    PeekStatusClass.serverError => danger,
    PeekStatusClass.unknown => secondary,
    null => switch (entry.failure?.kind) {
      null || PeekFailureKind.cancelled => secondary,
      _ => danger,
    },
  };
}

class _Home extends StatefulWidget {
  const _Home({
    required this.peek,
    required this.clients,
    required this.share,
    required this.onModeChanged,
  });

  final Peek peek;
  final ExampleClients clients;
  final PeekShareDelegate? share;

  /// Switches the theme; on the web page the switch is the page's, so
  /// there is none.
  final ValueChanged<ThemeMode>? onModeChanged;

  @override
  State<_Home> createState() => _HomeState();
}

/// What one row of the screen can do.
typedef _Scenario =
    ({
      String name,
      String detail,
      ScenarioGroup group,
      Future<void> Function() call,
    });

class _HomeState extends State<_Home> {
  static const PeekStrings _strings = PeekStrings();

  ExampleRoute _route = ExampleRoute.dio;
  final Set<String> _running = {};

  /// The call each scenario made last, by route and name, so its outcome
  /// can be read beside it.
  final Map<String, PeekId> _results = {};
  late final StreamSubscription<PeekStoreChange> _changes;

  @override
  void initState() {
    super.initState();
    _changes = widget.peek.store.changes.listen((_) => setState(() {}));
  }

  @override
  void dispose() {
    unawaited(_changes.cancel());
    super.dispose();
  }

  /// What the current route can do, as a name and a call.
  List<_Scenario> get _scenarios => switch (_route) {
    ExampleRoute.chopper => [
      for (final scenario in chopperScenarios)
        (
          name: scenario.name,
          detail: scenario.detail,
          group: scenario.group,
          call: () => scenario.run(widget.clients.chopper),
        ),
    ],
    ExampleRoute.http => [
      for (final scenario in httpScenarios)
        (
          name: scenario.name,
          detail: scenario.detail,
          group: scenario.group,
          call: () => scenario.run(widget.clients.httpClient),
        ),
    ],
    _ => [
      for (final scenario in scenarios)
        (
          name: scenario.name,
          detail: scenario.detail,
          group: scenario.group,
          call: () => scenario.run(widget.clients.dioOf(_route)),
        ),
    ],
  };

  String _key(String name) => '${_route.name}/$name';

  Future<void> _run(String name, Future<void> Function() call) async {
    if (_running.contains(name)) return;
    final key = _key(name);
    final store = widget.peek.store;
    final before = {for (final entry in store.entries) entry.id};
    setState(() => _running.add(name));
    try {
      await call();
    } on Object catch (_) {
      // Failing is what several of these are for; Peek already has it.
    } finally {
      if (mounted) {
        // Whatever the call added, newest last: that is its outcome.
        final added = store.entries.where((e) => !before.contains(e.id));
        setState(() {
          _running.remove(name);
          if (added.isNotEmpty) _results[key] = added.last.id;
        });
      }
    }
  }

  Future<void> _runAll() async {
    for (final scenario in _scenarios) {
      await _run(scenario.name, scenario.call);
    }
  }

  PeekEntry? _resultOf(String name) {
    final id = _results[_key(name)];
    return id == null ? null : widget.peek.store.find(id);
  }

  /// The outcome in a few words: `200 · 515 ms`, `timed out · 1.0 s`,
  /// `200 · 1.1 s · 2 hops`.
  String _outcomeOf(PeekEntry entry) {
    final code = entry.statusCode;
    final failure = entry.failure;
    final hops = entry.response?.redirects.length ?? 0;
    return [
      if (code != null)
        '$code'
      else if (failure != null)
        _strings.failureKind(failure.kind).toLowerCase()
      else
        _strings.pending.toLowerCase(),
      if (entry.duration case final elapsed?) _strings.elapsed(elapsed),
      if (hops == 1) '1 hop' else if (hops > 1) '$hops hops',
    ].join(' · ');
  }

  @override
  Widget build(BuildContext context) {
    final dark = Theme.of(context).brightness == Brightness.dark;
    final palette = dark ? _Palette.dark : _Palette.light;
    final padding = MediaQuery.paddingOf(context);
    final rows = _scenarios;

    return Scaffold(
      body: Stack(
        children: [
          ListView(
            padding: EdgeInsets.fromLTRB(
              20,
              padding.top + 8,
              20,
              padding.bottom + 112,
            ),
            children: [
              if (widget.onModeChanged case final onModeChanged?)
                Align(
                  alignment: Alignment.centerRight,
                  child: IconButton(
                    tooltip: 'Toggle theme',
                    color: palette.ink,
                    onPressed:
                        () => onModeChanged(
                          dark ? ThemeMode.light : ThemeMode.dark,
                        ),
                    icon: Icon(
                      dark
                          ? Icons.light_mode_outlined
                          : Icons.dark_mode_outlined,
                    ),
                  ),
                )
              else
                const SizedBox(height: 16),
              Text('Peek example', style: palette.title),
              const SizedBox(height: 8),
              Text(
                'Every route reports the calls it makes; only the way they '
                'reach Peek differs. Run a few, then open Peek.',
                style: palette.lead,
              ),
              const SizedBox(height: 28),
              _Caption('Adapter', trailing: _route.package, palette: palette),
              const SizedBox(height: 12),
              Wrap(
                spacing: 8,
                runSpacing: 8,
                children: [
                  for (final route in ExampleRoute.values)
                    _Chip(
                      label: route.name,
                      selected: route == _route,
                      palette: palette,
                      onTap: () => setState(() => _route = route),
                    ),
                ],
              ),
              for (final group in ScenarioGroup.values)
                if (rows.any((row) => row.group == group)) ...[
                  const SizedBox(height: 28),
                  _Caption(group.label, palette: palette),
                  const SizedBox(height: 4),
                  _Rows(
                    palette: palette,
                    children: [
                      for (final row in rows)
                        if (row.group == group)
                          _ScenarioRow(
                            name: row.name,
                            detail: row.detail,
                            outcome: switch (_resultOf(row.name)) {
                              null => null,
                              final entry => (
                                label: _outcomeOf(entry),
                                color: palette.outcome(entry),
                              ),
                            },
                            running: _running.contains(row.name),
                            palette: palette,
                            onTap: () => _run(row.name, row.call),
                          ),
                    ],
                  ),
                ],
              const SizedBox(height: 28),
              _Caption('Offline', palette: palette),
              const SizedBox(height: 4),
              _Rows(
                palette: palette,
                children: [
                  _Row(
                    title: 'Add calls without a network',
                    detail: 'The same screens, offline',
                    palette: palette,
                    onTap: () => fillWithDemoData(widget.peek),
                    trailing: _PillButton(
                      icon: Icons.add,
                      label: 'Add',
                      palette: palette,
                      onTap: () => fillWithDemoData(widget.peek),
                    ),
                  ),
                ],
              ),
            ],
          ),
          Positioned(
            left: 0,
            right: 0,
            bottom: 0,
            child: _BottomBar(
              count: widget.peek.store.length,
              palette: palette,
              onRunAll: _runAll,
              onOpen:
                  () =>
                      showPeek(context, peek: widget.peek, share: widget.share),
            ),
          ),
        ],
      ),
    );
  }
}

/// A heading in small capitals, with a note at the far end where there is
/// one.
class _Caption extends StatelessWidget {
  const _Caption(this.label, {required this.palette, this.trailing});

  final String label;
  final String? trailing;
  final _Palette palette;

  @override
  Widget build(BuildContext context) => Row(
    children: [
      Expanded(child: Text(label.toUpperCase(), style: palette.caption)),
      if (trailing case final trailing?)
        Text(trailing, style: palette.mono(12, color: palette.secondary)),
    ],
  );
}

/// One of the routes, as a pill; the one in use reads in the accent.
class _Chip extends StatelessWidget {
  const _Chip({
    required this.label,
    required this.selected,
    required this.palette,
    required this.onTap,
  });

  final String label;
  final bool selected;
  final _Palette palette;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) => Material(
    color: selected ? palette.accent : palette.fill,
    shape: const StadiumBorder(),
    child: InkWell(
      customBorder: const StadiumBorder(),
      onTap: onTap,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 9),
        child: Text(
          label,
          style: palette.mono(
            14,
            weight: FontWeight.w500,
            color: selected ? palette.onAccent : palette.chipInk,
          ),
        ),
      ),
    ),
  );
}

/// Rows with a hairline between each pair.
class _Rows extends StatelessWidget {
  const _Rows({required this.children, required this.palette});

  final List<Widget> children;
  final _Palette palette;

  @override
  Widget build(BuildContext context) => Column(
    children: [
      for (final (index, child) in children.indexed) ...[
        if (index > 0) Container(height: 1, color: palette.hairline),
        child,
      ],
    ],
  );
}

/// A name, a line about it and something to press at the end.
class _Row extends StatelessWidget {
  const _Row({
    required this.title,
    required this.detail,
    required this.trailing,
    required this.palette,
    required this.onTap,
    this.badge,
  });

  final String title;
  final String detail;
  final Widget? badge;
  final Widget trailing;
  final _Palette palette;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) => InkWell(
    onTap: onTap,
    child: Padding(
      padding: const EdgeInsets.symmetric(vertical: 12),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Wrap(
                  spacing: 8,
                  runSpacing: 4,
                  crossAxisAlignment: WrapCrossAlignment.center,
                  children: [
                    Text(title, style: palette.rowTitle),
                    if (badge case final badge?) badge,
                  ],
                ),
                const SizedBox(height: 2),
                Text(detail, style: palette.rowDetail),
              ],
            ),
          ),
          const SizedBox(width: 12),
          trailing,
        ],
      ),
    ),
  );
}

/// A scenario: run from the disc at its end, with how its last run went
/// beside its name.
class _ScenarioRow extends StatelessWidget {
  const _ScenarioRow({
    required this.name,
    required this.detail,
    required this.outcome,
    required this.running,
    required this.palette,
    required this.onTap,
  });

  final String name;
  final String detail;
  final ({String label, Color color})? outcome;
  final bool running;
  final _Palette palette;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) => _Row(
    title: name,
    detail: detail,
    palette: palette,
    onTap: onTap,
    badge: switch (outcome) {
      null => null,
      final outcome => _OutcomeChip(
        label: outcome.label,
        color: outcome.color,
        palette: palette,
      ),
    },
    trailing: _Disc(running: running, palette: palette, onTap: onTap),
  );
}

/// How a call went, in its colour: `404 · 690 ms`.
class _OutcomeChip extends StatelessWidget {
  const _OutcomeChip({
    required this.label,
    required this.color,
    required this.palette,
  });

  final String label;
  final Color color;
  final _Palette palette;

  @override
  Widget build(BuildContext context) => Container(
    padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
    decoration: BoxDecoration(
      color: color.withValues(alpha: 0.14),
      borderRadius: BorderRadius.circular(5),
    ),
    child: Text(
      label,
      style: palette.mono(11, weight: FontWeight.w500, color: color),
    ),
  );
}

/// The play button: a disc that spins while the call is in flight.
class _Disc extends StatelessWidget {
  const _Disc({
    required this.running,
    required this.palette,
    required this.onTap,
  });

  final bool running;
  final _Palette palette;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) => Material(
    color: palette.fill,
    shape: const CircleBorder(),
    child: InkWell(
      customBorder: const CircleBorder(),
      onTap: running ? null : onTap,
      child: SizedBox.square(
        dimension: 44,
        child: Center(
          child:
              running
                  ? SizedBox.square(
                    dimension: 18,
                    child: CircularProgressIndicator(
                      strokeWidth: 2,
                      color: palette.accent,
                    ),
                  )
                  : Icon(
                    Icons.play_arrow_rounded,
                    size: 22,
                    color: palette.accent,
                  ),
        ),
      ),
    ),
  );
}

/// A small outlined button with a glyph before its word.
class _PillButton extends StatelessWidget {
  const _PillButton({
    required this.icon,
    required this.label,
    required this.palette,
    required this.onTap,
  });

  final IconData icon;
  final String label;
  final _Palette palette;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) => Material(
    color: Colors.transparent,
    shape: StadiumBorder(side: BorderSide(color: palette.border)),
    child: InkWell(
      customBorder: const StadiumBorder(),
      onTap: onTap,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(14, 8, 16, 8),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(icon, size: 18, color: palette.ink),
            const SizedBox(width: 6),
            Text(label, style: palette.button.copyWith(fontSize: 14)),
          ],
        ),
      ),
    ),
  );
}

/// Run everything, or go and look: the two things always within reach.
class _BottomBar extends StatelessWidget {
  const _BottomBar({
    required this.count,
    required this.palette,
    required this.onRunAll,
    required this.onOpen,
  });

  final int count;
  final _Palette palette;
  final VoidCallback onRunAll;
  final VoidCallback onOpen;

  @override
  Widget build(BuildContext context) {
    final bottom = MediaQuery.paddingOf(context).bottom;
    return DecoratedBox(
      decoration: BoxDecoration(
        color: palette.background,
        boxShadow: [
          BoxShadow(
            color: palette.background,
            blurRadius: 16,
            offset: const Offset(0, -12),
          ),
        ],
      ),
      child: Padding(
        padding: EdgeInsets.fromLTRB(20, 12, 20, bottom + 12),
        child: Row(
          children: [
            Expanded(
              flex: 4,
              child: _BarButton(
                onTap: onRunAll,
                color: Colors.transparent,
                side: BorderSide(color: palette.border),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(Icons.playlist_play, size: 20, color: palette.ink),
                    const SizedBox(width: 8),
                    Text('Run all', style: palette.button),
                  ],
                ),
              ),
            ),
            const SizedBox(width: 12),
            Expanded(
              flex: 5,
              child: _BarButton(
                onTap: onOpen,
                color: palette.accent,
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text(
                      'Open Peek',
                      style: palette.button.copyWith(color: palette.onAccent),
                    ),
                    const SizedBox(width: 10),
                    Container(
                      constraints: const BoxConstraints(minWidth: 22),
                      height: 22,
                      padding: const EdgeInsets.symmetric(horizontal: 6),
                      decoration: BoxDecoration(
                        color: palette.badge,
                        borderRadius: BorderRadius.circular(11),
                      ),
                      alignment: Alignment.center,
                      child: Text(
                        '$count',
                        style: palette
                            .mono(12, weight: FontWeight.w600)
                            .copyWith(color: const Color(0xFFFFFFFF)),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// One of the bar's two buttons: full height, rounded, filled or outlined.
class _BarButton extends StatelessWidget {
  const _BarButton({
    required this.onTap,
    required this.color,
    required this.child,
    this.side = BorderSide.none,
  });

  final VoidCallback onTap;
  final Color color;
  final BorderSide side;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final shape = RoundedRectangleBorder(
      side: side,
      borderRadius: BorderRadius.circular(14),
    );
    return Material(
      color: color,
      shape: shape,
      child: InkWell(
        customBorder: shape,
        onTap: onTap,
        child: SizedBox(height: 50, child: child),
      ),
    );
  }
}
7
likes
160
points
624
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A beautiful in-app network inspector for Flutter: records the calls your client makes and shows them — searchable, filterable, exportable.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#debugging #devtools #http #logging #network

License

MIT (license)

Dependencies

flutter, meta

More

Packages that depend on peek