Skip to main content

sleuth 0.37.0 copy "sleuth: ^0.37.0" to clipboard
sleuth: ^0.37.0 copied to clipboard

In-app performance diagnostics overlay for Flutter. Surfaces jank, memory leaks, slow networks, GPU pressure, and widget anti-patterns. Every issue ships with a fix hint.

example/lib/main.dart

import 'dart:async' show Completer, TimeoutException, unawaited;
import 'dart:convert' show base64Encode, jsonEncode;
import 'dart:ui' as ui;
import 'dart:developer' as developer;
import 'dart:io' show Platform;

import 'package:flutter/foundation.dart' show kIsWeb;

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'
    show Clipboard, DeviceOrientation, SystemChrome;
import 'package:sleuth/sleuth.dart';

import 'custom_detectors/01_simple_structural_detector.dart';
import 'custom_detectors/02_runtime_callback_detector.dart';
import 'custom_detectors/03_hybrid_vm_structural_detector.dart';
import 'demos/combined_chat_demo.dart';
import 'demos/capture_driver.dart';
import 'demos/combined_social_feed_demo.dart';
import 'demos/custom_detector_cookbook_demo.dart';
import 'demos/custom_painter_demo.dart';
import 'demos/font_loading_demo.dart';
import 'demos/fps_stress_test_demo.dart';
import 'demos/frame_timing_capture_screen.dart';
import 'demos/gpu_pressure_demo.dart';
import 'demos/heavy_compute_capture_screen.dart';
import 'demos/heavy_compute_demo.dart';
import 'demos/high_level_setstate_demo.dart';
import 'demos/intrinsic_height_demo.dart';
import 'demos/keepalive_demo.dart';
import 'demos/memory_pressure_capture_screen.dart';
import 'demos/memory_pressure_demo.dart';
import 'demos/network_monitor_capture_screen.dart';
import 'demos/network_stress_demo.dart';
import 'demos/non_lazy_list_demo.dart';
import 'demos/platform_channel_capture_screen.dart';
import 'demos/repaint_capture_screen.dart';
import 'demos/platform_channel_demo.dart';
import 'demos/rebuild_activity_capture_screen.dart';
import 'demos/rebuild_hotspot_demo.dart';
import 'demos/repaint_boundary_demo.dart';
import 'demos/repaint_stress_demo.dart';
import 'demos/shrink_wrapped_sections_demo.dart';
import 'demos/stream_resource_capture_screen.dart';
import 'demos/tabbed_shell_demo.dart';
import 'demos/stream_resource_demo.dart';
import 'demos/tracked_resource_capture_screen.dart';
import 'demos/tracked_resource_demo.dart';
import 'demos/shader_jank_demo.dart';
import 'demos/uncached_image_demo.dart';
import 'fake_ai_adapter.dart';
import 'file_state_store.dart';
import 'harness_targeting.dart';

void main() {
  Sleuth.init();
  _countOverflowErrors();
  _registerDemoExtensions();
  // Capture mode gated behind a dart-define so ordinary profile-mode runs
  // see no extra Timeline.instantSync traffic. Flip on for the
  // runtimeVerified capture procedure:
  //   fvm flutter run --profile --no-dds --dart-define=SLEUTH_CAPTURE_MODE=true
  // Capture screens emit `sleuth.scenario.{begin,end}` +
  // `sleuth.issue.<id>.<severity>` instant events while enabled.
  const captureMode = bool.fromEnvironment('SLEUTH_CAPTURE_MODE');
  runApp(
    Sleuth.track(
      child: const SleuthDemoApp(),
      config: SleuthConfig(
        captureMode: captureMode,
        // Overlay state (trigger edge, card geometry, hidden issues,
        // severity filter) survives restarts through a JSON file.
        stateStore: FileSleuthStateStore(),
        // Ask AI in the overlay. See [_aiChatAdapter] for the local
        // Ollama default, `SLEUTH_AI_BASE_URL` and `SLEUTH_AI_FAKE`.
        aiChat: _aiChatAdapter(),
        // Rebuild-detector data sources (off by default to keep the
        // minimal install cheap). Both are needed for the Rebuild
        // Hotspot demo — and for every other rebuild-related issue:
        //
        //   • `enableDebugCallbacks: true` wires `debugOnRebuildDirtyWidget`
        //     so the detector can attribute per-type rebuild counts in
        //     DEBUG mode (produces `rebuild_debug_*` issue cards).
        //
        //   • `enableDeepDebugInstrumentation: true` flips
        //     `debugProfileBuildsEnabledUserWidgets` + installs the
        //     `FlutterTimeline.debugCollect()` drain, so PROFILE mode
        //     populates `RouteSession.rebuildCountsByType`. That powers
        //     the always-on `_RebuildStatsBanner` panel on the floating
        //     issues card and the `RebuildStatsPage` drilldown. The
        //     per-widget
        //     events are recorded inside the BUILD scopes, so the
        //     VM-timeline `rebuild_activity` build-time share includes
        //     that instrumentation cost.
        //
        // Without either flag the detector has no data to evaluate,
        // so no rebuild issue of any kind will ever surface — including
        // the Rebuild Hotspot (Dashboard) demo.
        //
        // Capture-mode caveat: deep debug instrumentation flips
        // `debugProfileBuildsEnabledUserWidgets`, which records a timeline
        // event for every user-widget build. That instrumentation runs
        // inside the BUILD scopes, so it inflates the build-time share
        // `rebuild_activity` measures and the BUILD durations
        // HeavyComputeDetector reads, and it multiplies the timeline
        // volume a capture exports. Disable it under captureMode so
        // captures measure the app's own build cost.
        enableDebugCallbacks: !captureMode,
        enableDeepDebugInstrumentation: !captureMode,
        // Cookbook custom detectors — see example/lib/custom_detectors/.
        // All three are attached to the overlay so the Custom Detector
        // Cookbook demo can exercise them end-to-end.
        customDetectors: [
          TooltipUsageDetector(),
          SlowFrameDetector(),
          RasterHotSpotDetector(),
        ],
      ),
    ),
  );
}

/// The overlay's AI chat adapter.
///
/// Defaults to a local Ollama server through its OpenAI-compatible API
/// (Ollama ignores the API key, which the adapter requires). On a device,
/// `localhost` is the device itself: point the app at the machine running
/// Ollama with
///
///     --dart-define=SLEUTH_AI_BASE_URL=http://192.168.1.20:11434
///
/// `--dart-define=SLEUTH_AI_FAKE=ok|fail|stall|partial|slow|empty` swaps
/// in [FakeAiChatAdapter], a scripted reply for checking the chat's
/// reply, failure, stall, partial-reply, slow-first-token (8 s) and
/// empty-reply states without a model.
AiChatAdapter _aiChatAdapter() {
  const fakeMode = String.fromEnvironment('SLEUTH_AI_FAKE');
  final fake = FakeAiChatAdapter.forMode(fakeMode);
  if (fake != null) return fake;
  if (fakeMode.isNotEmpty) {
    debugPrint('Sleuth demo: unknown SLEUTH_AI_FAKE=$fakeMode');
  }
  const baseUrl = String.fromEnvironment(
    'SLEUTH_AI_BASE_URL',
    defaultValue: 'http://localhost:11434',
  );
  return AiChatAdapter.openAi(
    apiKey: 'ollama',
    baseUrl: baseUrl,
    model: 'llama3.2',
    // Ollama loads the model on the first request, which can take longer
    // than the default 30 s wait for the first text.
    firstTokenTimeout: const Duration(seconds: 90),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: _navigatorKey,
      title: 'Sleuth Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF3B82F6),
        useMaterial3: true,
        brightness: Brightness.light,
      ),
      darkTheme: ThemeData(
        colorSchemeSeed: const Color(0xFF3B82F6),
        useMaterial3: true,
        brightness: Brightness.dark,
      ),
      home: const DemoHome(),
    );
  }
}

// ───────────────────────────────────────────────
// Home — categorized navigation to bad-pattern demos
// ───────────────────────────────────────────────
class DemoHome extends StatefulWidget {
  const DemoHome({super.key});

  @override
  State<DemoHome> createState() => _DemoHomeState();
}

class _DemoHomeState extends State<DemoHome> {
  bool _startDemoHandled = false;

  /// Opens one demo right after the first frame when the launch asks for
  /// it, so a profile build can be driven without touching the screen.
  /// The request comes from the process environment
  /// (`SLEUTH_START_DEMO=gpu_pressure`, for example through
  /// `xcrun devicectl device process launch --environment-variables
  /// '{"SLEUTH_START_DEMO":"gpu_pressure"}'`) or from
  /// `--dart-define=SLEUTH_START_DEMO=gpu_pressure`. The value is the demo
  /// title lower-cased with runs of non-alphanumerics folded to `_`.
  void _openStartDemo() {
    if (_startDemoHandled) return;
    _startDemoHandled = true;
    final request = _startDemoRequest();
    if (request == null) return;
    final demo = _demoForSlug(request);
    if (demo == null) {
      debugPrint('Sleuth demo: no demo matches SLEUTH_START_DEMO=$request');
      return;
    }
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;
      _pushDemo(Navigator.of(context), demo);
    });
  }

  @override
  Widget build(BuildContext context) {
    final categories = _demoCategories();
    _openStartDemo();

    return Scaffold(
      appBar: AppBar(
        title: const Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.pets, size: 20),
            SizedBox(width: 8),
            Text('Sleuth Demo'),
          ],
        ),
        centerTitle: true,
      ),
      body: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: categories.fold<int>(
          0,
          (sum, c) => sum + 1 + c.demos.length,
        ),
        itemBuilder: (context, index) {
          // Map flat index to category header or demo tile.
          var remaining = index;
          for (final category in categories) {
            if (remaining == 0) {
              return _CategoryHeader(
                title: category.title,
                icon: category.icon,
              );
            }
            remaining--;
            if (remaining < category.demos.length) {
              final demo = category.demos[remaining];
              return _DemoTile(demo: demo);
            }
            remaining -= category.demos.length;
          }
          return const SizedBox.shrink();
        },
      ),
    );
  }
}

// ── Category header ──

class _CategoryHeader extends StatelessWidget {
  const _CategoryHeader({required this.title, required this.icon});

  final String title;

  /// Drawn in the theme's primary color, which keeps 3:1 contrast on the
  /// page surface; the demo tiles keep their own colors.
  final IconData icon;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.only(top: 16, bottom: 8),
      child: Row(
        children: [
          Icon(icon, size: 18, color: theme.colorScheme.primary),
          const SizedBox(width: 8),
          Text(
            title,
            style: theme.textTheme.labelLarge?.copyWith(
              fontWeight: FontWeight.w700,
              color: theme.colorScheme.onSurface,
            ),
          ),
        ],
      ),
    );
  }
}

/// Every demo, grouped as the home screen shows them. Shared by the home
/// list, the launch hook, and the `ext.sleuthDemo.*` service extensions.
List<_DemoCategory> _demoCategories() => <_DemoCategory>[
  // ── Build ──
  _DemoCategory(
    title: 'Build',
    icon: Icons.construction,
    demos: [
      _DemoRoute(
        icon: Icons.refresh,
        title: 'High-Level setState',
        subtitle: 'SetStateScope detector',
        color: Colors.red,
        builder: (_) => const HighLevelSetStateDemo(),
      ),
      _DemoRoute(
        icon: Icons.insights,
        title: 'Rebuild Hotspot (Dashboard)',
        subtitle: 'Rebuilds banner + drilldown',
        color: Colors.pink,
        builder: (_) => const RebuildHotspotDemo(),
      ),
      _DemoRoute(
        icon: Icons.list,
        title: 'Non-Lazy ListView',
        subtitle: 'ListView detector',
        color: Colors.orange,
        builder: (_) => const NonLazyListDemo(),
      ),
      _DemoRoute(
        icon: Icons.view_agenda,
        title: 'Shrink-wrapped Sections',
        subtitle: 'Two equal shrinkWrap lists, two cards',
        color: Colors.deepOrange,
        builder: (_) => const ShrinkWrappedSectionsDemo(),
      ),
      _DemoRoute(
        icon: Icons.upload_file,
        title: 'CSV Import',
        subtitle: 'HeavyCompute warning + critical',
        color: Colors.purple,
        builder: (_) => const HeavyComputeDemo(),
      ),
    ],
  ),

  // ── Paint ──
  _DemoCategory(
    title: 'Paint',
    icon: Icons.format_paint,
    demos: [
      _DemoRoute(
        icon: Icons.graphic_eq,
        title: 'Live Waveform',
        subtitle: 'Repaint: aggregate + per-widget',
        color: Colors.blueGrey,
        builder: (_) => const RepaintStressDemo(),
      ),
      _DemoRoute(
        icon: Icons.brush,
        title: 'Always-Repaint CustomPainter',
        subtitle: 'CustomPainter detector',
        color: Colors.green,
        builder: (_) => const CustomPainterDemo(),
      ),
      _DemoRoute(
        icon: Icons.border_outer,
        title: 'Missing RepaintBoundary',
        subtitle: 'RepaintBoundary detector (structural)',
        color: Colors.deepPurple,
        builder: (_) => const RepaintBoundaryDemo(),
      ),
    ],
  ),

  // ── GPU & Rendering ──
  _DemoCategory(
    title: 'GPU & Rendering',
    icon: Icons.layers,
    demos: [
      _DemoRoute(
        icon: Icons.memory_outlined,
        title: 'GPU Pressure',
        subtitle: 'GpuPressure detector (hybrid)',
        color: Colors.deepOrange,
        builder: (_) => const GpuPressureDemo(),
      ),
      _DemoRoute(
        icon: Icons.blur_on,
        title: 'Shader Jank',
        subtitle: 'Pipeline builds (Vulkan, Skia)',
        color: Colors.indigo,
        builder: (_) => const ShaderJankDemo(),
      ),
      _DemoRoute(
        icon: Icons.local_fire_department,
        title: 'FPS Stress Test (~20 FPS)',
        subtitle: 'Heavy compute + GPU blur every frame',
        color: Colors.red,
        builder: (_) => const FpsStressTestDemo(),
      ),
    ],
  ),

  // ── Layout ──
  _DemoCategory(
    title: 'Layout',
    icon: Icons.grid_on,
    demos: [
      _DemoRoute(
        icon: Icons.height,
        title: 'IntrinsicHeight Abuse',
        subtitle: 'LayoutBottleneck detector',
        color: Colors.amber,
        builder: (_) => const IntrinsicHeightDemo(),
      ),
    ],
  ),

  // ── Memory ──
  _DemoCategory(
    title: 'Memory',
    icon: Icons.memory,
    demos: [
      _DemoRoute(
        icon: Icons.image,
        title: 'Uncached Images',
        subtitle: 'ImageMemory detector',
        color: Colors.teal,
        builder: (_) => const UncachedImageDemo(),
      ),
      _DemoRoute(
        icon: Icons.data_array,
        title: 'Memory Pressure',
        subtitle: 'MemoryPressure detector (VM-only)',
        color: Colors.purple,
        builder: (_) => const MemoryPressureDemo(),
      ),
      _DemoRoute(
        icon: Icons.all_inclusive,
        title: 'KeepAlive Overuse',
        subtitle: 'KeepAlive detector (>5 alive)',
        color: Colors.pink,
        builder: (_) => const KeepAliveDemo(),
      ),
      _DemoRoute(
        icon: Icons.stream,
        title: 'Stream Resource Leaks',
        subtitle: 'StreamResource: Timer + Controller leaks',
        color: Colors.deepPurple,
        builder: (_) => const StreamResourceDemo(),
      ),
      _DemoRoute(
        icon: Icons.bookmark_added,
        title: 'Tracked Resource Leaks',
        subtitle: 'Sleuth.trackResource retention tracking',
        color: Colors.indigo,
        builder: (_) => const TrackedResourceDemo(),
      ),
    ],
  ),

  // ── Network & I/O ──
  _DemoCategory(
    title: 'Network & I/O',
    icon: Icons.cloud,
    demos: [
      _DemoRoute(
        icon: Icons.search,
        title: 'Search + Gallery',
        subtitle: 'NetworkMonitor: slow / frequency / large',
        color: Colors.orange,
        builder: (_) => const NetworkStressDemo(),
      ),
      _DemoRoute(
        icon: Icons.settings_input_hdmi,
        title: 'Platform Channel Traffic',
        subtitle: 'PlatformChannel detector (>20/sec)',
        color: Colors.blueGrey,
        builder: (_) => const PlatformChannelDemo(),
      ),
      _DemoRoute(
        icon: Icons.font_download,
        title: 'Font Loading Stress',
        subtitle: 'FontLoading detector (>3 custom fonts)',
        color: Colors.deepOrange,
        builder: (_) => const FontLoadingDemo(),
      ),
    ],
  ),

  // ── Navigation ──
  _DemoCategory(
    title: 'Navigation',
    icon: Icons.tab,
    demos: [
      _DemoRoute(
        icon: Icons.view_carousel,
        title: 'Tabbed Shell',
        subtitle: 'IndexedStack tabs: one pattern each',
        color: Colors.cyan,
        builder: (_) => const TabbedShellDemo(),
      ),
    ],
  ),

  // ── Custom Detectors ──
  _DemoCategory(
    title: 'Custom Detectors',
    icon: Icons.extension,
    demos: [
      _DemoRoute(
        icon: Icons.extension_outlined,
        title: 'Custom Detector Cookbook',
        subtitle: 'Tooltip • Slow frame • Raster hot spot',
        color: Colors.deepPurple,
        builder: (_) => const CustomDetectorCookbookDemo(),
      ),
    ],
  ),

  // ── Combined ──
  _DemoCategory(
    title: 'Combined',
    icon: Icons.dashboard,
    demos: [
      _DemoRoute(
        icon: Icons.dynamic_feed,
        title: 'Combined: Social Feed',
        subtitle: 'Image • Layout • setState • Correlator',
        color: Colors.deepPurple,
        builder: (_) => const CombinedSocialFeedDemo(),
      ),
      _DemoRoute(
        icon: Icons.chat,
        title: 'Combined: Chat App',
        subtitle: 'SetState + KeepAlive + Channel',
        color: Colors.blue,
        builder: (_) => const CombinedChatDemo(),
      ),
    ],
  ),

  // ── Capture Helpers ──
  // Operator-only tooling for the runtimeVerified bracket-recording
  // procedure (see doc/capture_procedure.md). Each helper drives
  // Sleuth.markScenarioBegin/End around a workload at known magnitude
  // (below / at / above the bracket band) so detectors emit captured
  // trace records on real devices.
  _DemoCategory(
    title: 'Capture Helpers',
    icon: Icons.videocam,
    demos: [
      _DemoRoute(
        icon: Icons.speed,
        title: 'HeavyCompute',
        subtitle: 'heavy_compute warning + critical',
        color: Colors.purple,
        builder: (_) => const HeavyComputeCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.refresh,
        title: 'RebuildActivity',
        subtitle: 'rebuild_activity warning + critical',
        color: Colors.teal,
        builder: (_) => const RebuildActivityCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.timeline,
        title: 'FrameTiming (jank_detected)',
        subtitle: 'jank_detected warning bracket (60Hz)',
        color: Colors.indigo,
        builder: (_) => const FrameTimingCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.data_array,
        title: 'MemoryPressure',
        subtitle: 'heap_growing warning bracket',
        color: Colors.purple,
        builder: (_) => const MemoryPressureCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.cloud_download,
        title: 'NetworkMonitor',
        subtitle: 'slow_request warning + critical brackets',
        color: Colors.orange,
        builder: (_) => const NetworkMonitorCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.settings_input_hdmi,
        title: 'PlatformChannel',
        subtitle: 'platform_channel_traffic warning bracket',
        color: Colors.blueGrey,
        builder: (_) => const PlatformChannelCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.brush,
        title: 'Repaint',
        subtitle: 'excessive_repaint warning bracket',
        color: Colors.pink,
        builder: (_) => const RepaintCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.stream,
        title: 'StreamResource',
        subtitle: 'stream_resource_growth warning bracket',
        color: Colors.deepPurple,
        builder: (_) => const StreamResourceCaptureScreen(),
      ),
      _DemoRoute(
        icon: Icons.track_changes,
        title: 'TrackedResource',
        subtitle: 'tracked_resource_concurrent warning',
        color: Colors.teal,
        builder: (_) => const TrackedResourceCaptureScreen(),
      ),
    ],
  ),
];

final _navigatorKey = GlobalKey<NavigatorState>();

_DemoRoute? _demoForSlug(String request) {
  final wanted = _demoSlug(request);
  for (final category in _demoCategories()) {
    for (final demo in category.demos) {
      if (_demoSlug(demo.title) == wanted) return demo;
    }
  }
  return null;
}

void _pushDemo(NavigatorState navigator, _DemoRoute demo) {
  navigator.push(
    MaterialPageRoute<void>(
      settings: RouteSettings(name: '/demo/${demo.title}'),
      builder: demo.builder,
    ),
  );
}

/// `ext.sleuthDemo.open` (`demo: <slug>`) pushes a demo and
/// `ext.sleuthDemo.pop` returns to the home screen, so a profile build can
/// be walked from a VM service client without touching the screen.
void _registerDemoExtensions() {
  developer.registerExtension('ext.sleuthDemo.open', (method, params) async {
    final request = params['demo'] ?? '';
    final demo = _demoForSlug(request);
    final navigator = _navigatorKey.currentState;
    if (demo == null || navigator == null) {
      return developer.ServiceExtensionResponse.error(
        developer.ServiceExtensionResponse.extensionError,
        jsonEncode({
          'error': demo == null ? 'unknown_demo' : 'no_navigator',
          'demo': request,
        }),
      );
    }
    _pushDemo(navigator, demo);
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'opened': demo.title, 'route': '/demo/${demo.title}'}),
    );
  });
  // Remote interaction for profile-build walks: synthetic pointer events
  // go through the real gesture arena, scrolls through the real
  // ScrollPosition, so detectors see what a finger would produce. Targets
  // come from harness_targeting.dart: the foreground copy of a label (an
  // open overlay page over the app, the current route over the ones
  // below, the shown IndexedStack tab), and a tap that something painted
  // over its target would take is refused as `obscured`.
  developer.registerExtension('ext.sleuthDemo.tap', (method, params) async {
    // `x` and `y` (logical px) tap a point, for controls that share a label.
    if (params.containsKey('x') || params.containsKey('y')) {
      final x = double.tryParse(params['x'] ?? '');
      final y = double.tryParse(params['y'] ?? '');
      if (x == null || y == null) {
        return _demoError({'error': 'bad_args', 'reason': 'needs x and y'});
      }
      await _tapAt(Offset(x, y));
      return developer.ServiceExtensionResponse.result(
        jsonEncode({'tapped': 'point', 'x': x, 'y': y}),
      );
    }
    final label = params['label'] ?? '';
    final text = params['text'] ?? '';
    if (label.isEmpty && text.isEmpty) {
      return _demoError({
        'error': 'bad_args',
        'reason': 'needs text, label, or x and y',
      });
    }
    final wanted = label.isNotEmpty ? label : text;
    final element = label.isNotEmpty
        ? findSemanticsLabel(label)
        : findText(text);
    if (element == null) {
      return _demoError({'error': 'not_found', 'text': wanted});
    }
    final target = await prepareTap(element);
    final position = target.position;
    if (position == null) {
      return _demoError({'error': target.error, 'text': wanted});
    }
    await _tapAt(position, viewId: target.viewId);
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'tapped': wanted, 'x': position.dx, 'y': position.dy}),
    );
  });
  // Writes to the focused field, else to the last foreground field a tap
  // would reach, so an open AI chat page gets the text, not the demo's
  // search field behind it.
  developer.registerExtension('ext.sleuthDemo.type', (method, params) async {
    final text = params['text'] ?? '';
    final state = findTypingTarget();
    if (state == null) return _demoError({'error': 'no_text_field'});
    final field = state.widget;
    field.controller.text = text;
    field.onChanged?.call(text);
    if (params['submit'] == 'true') field.onSubmitted?.call(text);
    await waitForFrame();
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'typed': text, 'submitted': params['submit'] == 'true'}),
    );
  });
  // `pixels` (default 600) over `ms` (default 600; 0 jumps).
  developer.registerExtension('ext.sleuthDemo.scroll', (method, params) async {
    final pixels = params.containsKey('pixels')
        ? double.tryParse(params['pixels']!)
        : 600.0;
    final ms = params.containsKey('ms') ? int.tryParse(params['ms']!) : 600;
    if (pixels == null || !pixels.isFinite || ms == null || ms < 0) {
      return _demoError({
        'error': 'bad_args',
        'reason': 'pixels must be a number and ms an integer >= 0',
      });
    }
    final horizontal = params['axis'] == 'horizontal';
    final state = findScrollable(horizontal: horizontal);
    if (state == null) return _demoError({'error': 'no_scrollable'});
    final position = state.position;
    final from = position.pixels;
    final target = (from + pixels).clamp(
      position.minScrollExtent,
      position.maxScrollExtent,
    );
    if (ms == 0) {
      position.jumpTo(target);
    } else {
      // Frames stop in the background, and the animation with them.
      final finished = await position
          .animateTo(
            target,
            duration: Duration(milliseconds: ms),
            curve: Curves.easeOutCubic,
          )
          .then((_) => true)
          .timeout(
            Duration(milliseconds: ms) + const Duration(seconds: 2),
            onTimeout: () => false,
          );
      if (!finished) {
        return _demoError({
          'error': 'timeout',
          'from': from,
          'to': target,
          'at': position.pixels,
        });
      }
    }
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'from': from, 'to': target}),
    );
  });
  developer.registerExtension('ext.sleuthDemo.fling', (method, params) async {
    final dy = double.tryParse(params['dy'] ?? '') ?? -500;
    final dx = double.tryParse(params['dx'] ?? '') ?? 0;
    final ms = int.tryParse(params['ms'] ?? '') ?? 120;
    final horizontal = dx.abs() > dy.abs();
    final state = findScrollable(horizontal: horizontal);
    final element = state?.context as Element?;
    final center = element == null ? null : centerOf(element);
    final viewId = element == null ? null : viewIdOf(element);
    if (center == null || viewId == null) {
      return _demoError({'error': 'no_scrollable'});
    }
    await _dragFrom(center, Offset(dx, dy), ms, viewId: viewId);
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'flung': true, 'dx': dx, 'dy': dy}),
    );
  });
  developer.registerExtension('ext.sleuthDemo.pop', (method, params) async {
    final navigator = _navigatorKey.currentState;
    final popped = navigator != null && navigator.canPop();
    if (popped) navigator.pop();
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'popped': popped}),
    );
  });
  // Hands-free capture legs for the time-share brackets
  // (doc/capture_procedure.md). `captureLeg` starts a leg and returns at
  // once; `captureResult` polls it.
  developer.registerExtension('ext.sleuthDemo.captureLeg', (
    method,
    params,
  ) async {
    final response = await startCaptureLeg(
      detector: params['detector'] ?? '',
      tier: params['tier'] ?? 'warning',
      role: params['leg'] ?? '',
    );
    return developer.ServiceExtensionResponse.result(jsonEncode(response));
  });
  developer.registerExtension('ext.sleuthDemo.captureResult', (
    method,
    params,
  ) async {
    return developer.ServiceExtensionResponse.result(
      jsonEncode(
        CaptureDriver.instance.result(consume: params['consume'] == 'true'),
      ),
    );
  });
  developer.registerExtension('ext.sleuthDemo.vmAxes', (method, params) async {
    return developer.ServiceExtensionResponse.result(
      jsonEncode(readVmAxes(reset: params['reset'] == 'true')),
    );
  });
  // System back as the Android back button sends it: the Sleuth overlay
  // closes its innermost layer first; `handled: false` means the app
  // (or the OS) got it.
  developer.registerExtension('ext.sleuthDemo.back', (method, params) async {
    // handlePopRoute is the binding entry point for the platform back
    // message; calling it here reproduces a hardware back press.
    // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
    final handled = await WidgetsBinding.instance.handlePopRoute();
    await waitForFrame();
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'handled': handled}),
    );
  });
  developer.registerExtension('ext.sleuthDemo.clipboard', (
    method,
    params,
  ) async {
    final data = await Clipboard.getData(Clipboard.kTextPlain);
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'text': data?.text}),
    );
  });
  // Drives the overlay through its state: `action` = open | close | hide |
  // undo | restoreAll | toggleSeverity (with `severity` = critical |
  // warning | ok) | setTheme (with `preset` = hc_dark | hc_light |
  // seed:<hex> | none). `hide` hides the first visible card; `undo`
  // restores the most recently hidden key.
  developer.registerExtension('ext.sleuthDemo.overlay', (method, params) async {
    final state = Sleuth.overlayUiState;
    if (state == null) return _demoError({'error': 'no_controller'});
    final action = params['action'] ?? '';
    final result = <String, Object?>{'action': action};
    switch (action) {
      case 'open':
        state.dashboardOpen = true;
      case 'close':
        state.dashboardOpen = false;
      case 'hide':
        final visible = state.visibleIssues(_currentIssues());
        if (visible.isEmpty) return _demoError({'error': 'no_visible_issue'});
        final key = OverlayUiState.hideKeyFor(visible.first);
        state.hide(key);
        result['hidden'] = key;
      case 'undo':
        if (state.hiddenKeys.isEmpty) {
          return _demoError({'error': 'nothing_hidden'});
        }
        final key = state.hiddenKeys.last;
        state.unhide(key);
        result['restored'] = key;
      case 'restoreAll':
        state.restoreAll();
      case 'setTheme':
        final preset = params['preset'] ?? '';
        if (preset == 'none') {
          Sleuth.updateTheme(null);
        } else {
          final theme = _themePreset(preset);
          if (theme == null) {
            return _demoError({'error': 'bad_preset', 'preset': preset});
          }
          Sleuth.updateTheme(theme);
        }
        result['preset'] = preset;
      case 'toggleSeverity':
        final severity = IssueSeverity.values
            .where((s) => s.name == params['severity'])
            .firstOrNull;
        if (severity == null) {
          return _demoError({
            'error': 'bad_severity',
            'severity': params['severity'],
          });
        }
        result['toggled'] = state.toggleSeverity(severity);
      default:
        return _demoError({'error': 'unknown_action', 'action': action});
    }
    await waitForFrame();
    return developer.ServiceExtensionResponse.result(
      jsonEncode({...result, ..._overlayStateJson(state)}),
    );
  });
  // Theme mode as the header toggle sets it: `mode` = system | light |
  // dark. An `updateTheme` override is kept and shows under System.
  developer.registerExtension('ext.sleuthDemo.theme', (method, params) async {
    final state = Sleuth.overlayUiState;
    if (state == null) return _demoError({'error': 'no_controller'});
    final mode = SleuthThemeMode.values
        .where((m) => m.name == params['mode'])
        .firstOrNull;
    if (mode == null) {
      return _demoError({'error': 'bad_mode', 'mode': params['mode']});
    }
    state.themeMode = mode;
    await waitForFrame();
    return developer.ServiceExtensionResponse.result(
      jsonEncode({'themeMode': state.themeMode.name}),
    );
  });
  // Accessibility state for the device pass: platform settings, the text
  // scale the overlay uses, overflow reports since launch, and every
  // labelled or actionable semantics node with its size and actions.
  //
  // The semantics tree is built for the dump only and released after it,
  // so the dump does not leave semantics on (which changes what Sleuth's
  // debug paint counts include). With frames stopped (the app in the
  // background) no tree is built, and the call returns `unavailable`.
  developer.registerExtension('ext.sleuthDemo.a11y', (method, params) async {
    final handle = SemanticsBinding.instance.ensureSemantics();
    try {
      if (!await waitForFrame()) {
        return _demoError({'error': 'unavailable', 'reason': 'no_frame'});
      }
      final dispatcher = WidgetsBinding.instance.platformDispatcher;
      final features = dispatcher.accessibilityFeatures;
      return developer.ServiceExtensionResponse.result(
        jsonEncode({
          'textScale': dispatcher.textScaleFactor,
          'overlayTextScale':
              _textScaleAt('FloatingIssuesCard') ??
              _textScaleAt('TriggerButton'),
          'highContrast': features.highContrast,
          'disableAnimations': features.disableAnimations,
          'reduceMotion': features.reduceMotion,
          'boldText': features.boldText,
          'accessibleNavigation': features.accessibleNavigation,
          'overflowErrors': _overflowErrors,
          'semantics': _semanticsNodes(),
        }),
      );
    } finally {
      handle.dispose();
    }
  });
  // PNG of the whole screen (base64) for hands-free visual checks. Uses
  // the root layer, so it includes the overlay. With frames stopped (the
  // app in the background) the layer is stale, and the call returns
  // `unavailable`.
  developer.registerExtension('ext.sleuthDemo.screenshot', (
    method,
    params,
  ) async {
    if (!await waitForFrame()) {
      return _demoError({'error': 'unavailable', 'reason': 'no_frame'});
    }
    final view = RendererBinding.instance.renderViews.first;
    // The root layer is the only whole-screen surface; `debugLayer` is
    // debug-only and this must work in profile too.
    // ignore: invalid_use_of_protected_member
    final layer = view.layer;
    if (layer is! OffsetLayer) return _demoError({'error': 'no_layer'});
    final bounds = Offset.zero & view.flutterView.physicalSize;
    final image = await layer.toImage(bounds);
    final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
    image.dispose();
    if (bytes == null) return _demoError({'error': 'no_bytes'});
    return developer.ServiceExtensionResponse.result(
      jsonEncode({
        'png': base64Encode(bytes.buffer.asUint8List()),
        'width': image.width,
        'height': image.height,
      }),
    );
  });
  // Orientation for a hands-free rotation check: `value` = portrait |
  // landscape | all. iOS 16+ and Android rotate the app to a forced
  // orientation even when the device is held the other way. The size is
  // read once the view has the requested shape (up to 3 s; `settled:
  // false` when it had not), or for `all` after up to 1 s for a pending
  // rotation to land.
  developer.registerExtension('ext.sleuthDemo.orientation', (
    method,
    params,
  ) async {
    final value = params['value'] ?? '';
    final orientations = switch (value) {
      'portrait' => const [DeviceOrientation.portraitUp],
      'landscape' => const [
        DeviceOrientation.landscapeLeft,
        DeviceOrientation.landscapeRight,
      ],
      'all' => const <DeviceOrientation>[],
      _ => null,
    };
    if (orientations == null) {
      return _demoError({'error': 'bad_value', 'value': value});
    }
    final view = WidgetsBinding.instance.platformDispatcher.views.first;
    final start = view.physicalSize;
    await SystemChrome.setPreferredOrientations(orientations);
    final settled = await _waitForMetrics(
      () => switch (value) {
        'portrait' => view.physicalSize.height >= view.physicalSize.width,
        'landscape' => view.physicalSize.width > view.physicalSize.height,
        _ => view.physicalSize != start,
      },
      timeout: Duration(seconds: value == 'all' ? 1 : 3),
    );
    return developer.ServiceExtensionResponse.result(
      jsonEncode({
        'value': value,
        'width': view.physicalSize.width / view.devicePixelRatio,
        'height': view.physicalSize.height / view.devicePixelRatio,
        // Any shape is what `all` asks for.
        'settled': settled || value == 'all',
      }),
    );
  });
  developer.registerExtension('ext.sleuthDemo.overlayState', (
    method,
    params,
  ) async {
    final state = Sleuth.overlayUiState;
    if (state == null) return _demoError({'error': 'no_controller'});
    return developer.ServiceExtensionResponse.result(
      jsonEncode(_overlayStateJson(state)),
    );
  });
}

/// RenderFlex overflow reports seen since launch, for
/// `ext.sleuthDemo.a11y`. Overflow reports are assert-gated, so only debug
/// builds count them.
int _overflowErrors = 0;

/// Counts overflow reports, then hands every error to the previous
/// handler.
void _countOverflowErrors() {
  final previous = FlutterError.onError;
  FlutterError.onError = (details) {
    if (details.summary.toString().contains('overflowed')) _overflowErrors++;
    (previous ?? FlutterError.presentError)(details);
  };
}

/// Text scale at the first element whose widget type is [typeName]
/// (an overlay widget), or null when none is mounted.
double? _textScaleAt(String typeName) {
  double? found;
  void visit(Element element) {
    if (found != null) return;
    if (element.widget.runtimeType.toString() == typeName) {
      // Read without registering a dependency: this runs from a service
      // extension, outside any build.
      final scaler = element
          .getInheritedWidgetOfExactType<MediaQuery>()
          ?.data
          .textScaler;
      found = scaler == null ? null : scaler.scale(10) / 10;
      return;
    }
    element.visitChildren(visit);
  }

  WidgetsBinding.instance.rootElement?.visitChildren(visit);
  return found;
}

/// Labelled or actionable semantics nodes with their logical size.
List<Map<String, Object?>> _semanticsNodes() {
  final nodes = <Map<String, Object?>>[];
  for (final view in RendererBinding.instance.renderViews) {
    final root = view.owner?.semanticsOwner?.rootSemanticsNode;
    if (root == null) continue;
    final dpr = view.flutterView.devicePixelRatio;
    void visit(SemanticsNode node) {
      final data = node.getSemanticsData();
      final actions = [
        for (final action in SemanticsAction.values)
          if (data.hasAction(action) && action != SemanticsAction.customAction)
            action.name,
        for (final id in data.customSemanticsActionIds ?? const <int>[])
          ?CustomSemanticsAction.getAction(id)?.label,
      ];
      if (!node.isMergedIntoParent &&
          (data.label.isNotEmpty || actions.isNotEmpty)) {
        var rect = node.rect;
        for (SemanticsNode? n = node; n != null; n = n.parent) {
          final t = n.transform;
          if (t != null) rect = MatrixUtils.transformRect(t, rect);
        }
        nodes.add({
          'label': data.label,
          'value': data.value,
          'y': double.parse((rect.top / dpr).toStringAsFixed(1)),
          'w': double.parse((rect.width / dpr).toStringAsFixed(1)),
          'h': double.parse((rect.height / dpr).toStringAsFixed(1)),
          'button': data.flagsCollection.isButton,
          'actions': actions,
        });
      }
      node.visitChildren((child) {
        visit(child);
        return true;
      });
    }

    visit(root);
  }
  return nodes;
}

/// `SleuthThemeData` for `ext.sleuthDemo.overlay action=setTheme`:
/// `hc_dark`, `hc_light`, `seed:<hex>` (e.g. `seed:0xFF00838F`) or
/// `none` (clears the override).
SleuthThemeData? _themePreset(String preset) {
  if (preset == 'hc_dark') return const SleuthThemeData.highContrastDark();
  if (preset == 'hc_light') return const SleuthThemeData.highContrastLight();
  if (preset.startsWith('seed:')) {
    final value = int.tryParse(preset.substring(5));
    if (value == null) return null;
    final brightness =
        WidgetsBinding.instance.platformDispatcher.platformBrightness;
    return _seedThemes.putIfAbsent(
      (value, brightness),
      () => SleuthThemeData.fromSeed(
        Color(value | 0xFF000000),
        brightness: brightness,
      ),
    );
  }
  return null;
}

/// Built once per seed and brightness: the overlay compares themes by
/// identity.
final Map<(int, Brightness), SleuthThemeData> _seedThemes = {};

developer.ServiceExtensionResponse _demoError(Map<String, Object?> body) =>
    developer.ServiceExtensionResponse.error(
      developer.ServiceExtensionResponse.extensionError,
      jsonEncode(body),
    );

List<PerformanceIssue> _currentIssues() =>
    Sleuth.exportSnapshot()?.currentIssues ?? const [];

/// Persisted overlay state plus `dashboardOpen`, `uiStateReady` and the
/// number of cards the overlay shows.
Map<String, Object?> _overlayStateJson(OverlayUiState state) => {
  ...state.toJson(),
  'dashboardOpen': state.dashboardOpen,
  'uiStateReady': Sleuth.isOverlayUiStateReady,
  'visibleIssueCount': state.visibleIssues(_currentIssues()).length,
};

/// Demo slug of the capture screen for each `captureLeg` detector.
const Map<String, String> _captureScreenSlugs = {
  'rebuild': 'rebuildactivity',
  'repaint': 'repaint',
};

const Set<String> _captureTiers = {'warning', 'critical'};
const Set<String> _captureRoles = {'below', 'at', 'above'};

/// Starts a capture leg for `ext.sleuthDemo.captureLeg`: brings the
/// detector's capture screen to the front (pops the routes above a
/// covered one, opens one when none is mounted), waits up to 3 s for it
/// to be in front, and starts the same leg its buttons run. Returns
/// `{started: true}` without waiting for the leg, or `{error: ...}`
/// (`busy`, `not_capture_mode`, `vm_disconnected`, `bad_args`,
/// `bad_provenance` with a `detail`, `no_navigator`, `screen_not_ready`,
/// `not_in_front`).
@visibleForTesting
Future<Map<String, Object?>> startCaptureLeg({
  required String detector,
  required String tier,
  required String role,
  Duration readyTimeout = const Duration(seconds: 3),
}) async {
  final slug = _captureScreenSlugs[detector];
  if (slug == null ||
      !_captureTiers.contains(tier) ||
      !_captureRoles.contains(role) ||
      (detector == 'repaint' && tier != 'warning')) {
    return {'error': 'bad_args'};
  }
  final driver = CaptureDriver.instance;
  if (driver.isBusy) return {'error': 'busy'};
  final capture = Sleuth.diagnoseCaptureState();
  if (!capture.captureMode) return {'error': 'not_capture_mode'};
  if (!capture.vmConnected) return {'error': 'vm_disconnected'};
  final provenanceProblem = currentCaptureProvenance().problem;
  if (provenanceProblem != null) {
    return {'error': 'bad_provenance', 'detail': provenanceProblem};
  }

  final navigator = _navigatorKey.currentState;
  final demo = _demoForSlug(slug);
  final front = await bringCaptureScreenToFront(
    detector: detector,
    navigator: navigator,
    pushScreen: navigator == null || demo == null
        ? null
        : () => _pushDemo(navigator, demo),
    timeout: readyTimeout,
  );
  final screen = front.screen;
  if (screen == null) return {'error': front.error};
  if (!driver.begin('$detector/$tier/$role')) return {'error': 'busy'};
  unawaited(driver.runLeg(screen.runner, tier, role));
  return {'started': true, 'leg': '$detector/$tier/$role'};
}

/// Detector last/peak time shares for `ext.sleuthDemo.vmAxes`. With
/// [reset] both detectors' capture state is cleared after the read; a
/// reset while a capture leg runs returns `{error: busy}` and leaves the
/// leg's peak alone.
@visibleForTesting
Map<String, Object?> readVmAxes({bool reset = false}) {
  if (reset && CaptureDriver.instance.isBusy) return {'error': 'busy'};
  final rebuild = Sleuth.rebuildDetector;
  final repaint = Sleuth.repaintDetector;
  final axes = <String, Object?>{
    'buildLast': rebuild?.lastObservedBuildPercent ?? 0.0,
    'buildPeak': rebuild?.peakObservedBuildPercent ?? 0.0,
    'paintLast': repaint?.lastObservedPaintPercent ?? 0.0,
    'paintPeak': repaint?.peakObservedPaintPercent ?? 0.0,
    'vmConnected': Sleuth.diagnoseCaptureState().vmConnected,
  };
  if (reset) {
    rebuild?.resetCaptureState();
    repaint?.resetCaptureState();
  }
  return axes;
}

// ── Demo tile ──

class _DemoTile extends StatelessWidget {
  const _DemoTile({required this.demo});

  final _DemoRoute demo;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Card(
        clipBehavior: Clip.antiAlias,
        child: ListTile(
          leading: CircleAvatar(
            backgroundColor: demo.color.withValues(alpha: 0.15),
            child: Icon(demo.icon, color: demo.color),
          ),
          title: Text(demo.title, style: theme.textTheme.titleMedium),
          subtitle: Text(
            demo.subtitle,
            style: theme.textTheme.bodySmall?.copyWith(
              color: theme.colorScheme.onSurfaceVariant,
            ),
          ),
          trailing: const Icon(Icons.chevron_right),
          onTap: () => Navigator.push(
            context,
            MaterialPageRoute(
              settings: RouteSettings(name: '/demo/${demo.title}'),
              builder: demo.builder,
            ),
          ),
        ),
      ),
    );
  }
}

/// Waits up to [timeout] for the view metrics to make [settled] true,
/// checking after each metrics change. False when they did not in time.
Future<bool> _waitForMetrics(
  bool Function() settled, {
  required Duration timeout,
}) async {
  if (settled()) return true;
  final done = Completer<void>();
  final observer = _MetricsObserver(() {
    if (!done.isCompleted && settled()) done.complete();
  });
  WidgetsBinding.instance.addObserver(observer);
  try {
    await done.future.timeout(timeout);
    return true;
  } on TimeoutException {
    return false;
  } finally {
    WidgetsBinding.instance.removeObserver(observer);
  }
}

class _MetricsObserver with WidgetsBindingObserver {
  _MetricsObserver(this.onChange);

  final VoidCallback onChange;

  @override
  void didChangeMetrics() => onChange();
}

int _syntheticPointer = 900;

/// Down, 60 ms, up at [position] in the view [viewId] (0, the implicit
/// view, for a bare point).
Future<void> _tapAt(Offset position, {int viewId = 0}) async {
  final binding = WidgetsBinding.instance;
  final pointer = _syntheticPointer++;
  binding.handlePointerEvent(
    PointerDownEvent(pointer: pointer, position: position, viewId: viewId),
  );
  await Future<void>.delayed(const Duration(milliseconds: 60));
  binding.handlePointerEvent(
    PointerUpEvent(pointer: pointer, position: position, viewId: viewId),
  );
  await waitForFrame();
}

Future<void> _dragFrom(
  Offset start,
  Offset delta,
  int ms, {
  int viewId = 0,
}) async {
  final binding = WidgetsBinding.instance;
  final pointer = _syntheticPointer++;
  const stepMs = 16;
  final steps = (ms / stepMs).clamp(2, 60).round();
  var clock = Duration(milliseconds: DateTime.now().millisecondsSinceEpoch);
  var position = start;
  binding.handlePointerEvent(
    PointerDownEvent(
      pointer: pointer,
      position: position,
      timeStamp: clock,
      viewId: viewId,
    ),
  );
  for (var i = 1; i <= steps; i++) {
    await Future<void>.delayed(const Duration(milliseconds: stepMs));
    clock += const Duration(milliseconds: stepMs);
    final next = start + delta * (i / steps);
    binding.handlePointerEvent(
      PointerMoveEvent(
        pointer: pointer,
        position: next,
        delta: next - position,
        timeStamp: clock,
        viewId: viewId,
      ),
    );
    position = next;
  }
  binding.handlePointerEvent(
    PointerUpEvent(
      pointer: pointer,
      position: position,
      timeStamp: clock,
      viewId: viewId,
    ),
  );
  await waitForFrame();
}

String? _startDemoRequest() {
  const defined = String.fromEnvironment('SLEUTH_START_DEMO');
  final fromEnvironment = kIsWeb
      ? null
      : Platform.environment['SLEUTH_START_DEMO'];
  final value = fromEnvironment != null && fromEnvironment.isNotEmpty
      ? fromEnvironment
      : defined;
  return value.isEmpty ? null : value;
}

String _demoSlug(String title) => title
    .toLowerCase()
    .replaceAll(RegExp(r'[^a-z0-9]+'), '_')
    .replaceAll(RegExp(r'^_+|_+$'), '');

// ── Data classes ──

class _DemoCategory {
  const _DemoCategory({
    required this.title,
    required this.icon,
    required this.demos,
  });

  final String title;
  final IconData icon;
  final List<_DemoRoute> demos;
}

class _DemoRoute {
  const _DemoRoute({
    required this.icon,
    required this.title,
    required this.subtitle,
    required this.color,
    required this.builder,
  });

  final IconData icon;
  final String title;
  final String subtitle;
  final Color color;
  final WidgetBuilder builder;
}
6
likes
160
points
335
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

In-app performance diagnostics overlay for Flutter. Surfaces jank, memory leaks, slow networks, GPU pressure, and widget anti-patterns. Every issue ships with a fix hint.

Repository (GitHub)
View/report issues
Contributing

Topics

#performance #debugging #developer-tools #profiling

License

MIT (license)

Dependencies

flutter, meta, path, vm_service

More

Packages that depend on sleuth