mural 1.0.0-dev.0 copy "mural: ^1.0.0-dev.0" to clipboard
mural: ^1.0.0-dev.0 copied to clipboard

Capture any Flutter widget as an image, at any size. Screenshot huge lists, full pages, and even unseen widgets — past the GPU texture limit, always crisp, in constant memory.

example/lib/main.dart

// mural example — every capability, one file.
//
// ONE file on purpose — pub.dev renders example/lib/main.dart as the
// package's Example tab. Splitting it hides everything past main.dart
// from that page. Do not modularize.
//
// Three tabs, one per capture door:
//   Offscreen  — capture(): pixel ratios, constraints, backgrounds and
//                transparency, theme + directionality inheritance, the
//                ready recipes (precacheImage + a delayed data signal),
//                and every typed error
//   On screen  — captureBoundary(): the frozen moment on a live
//                animation, a burst series, a plain RepaintBoundary,
//                and the unmounted-key error
//   Stream     — captureInto(): live MuralProgress, chunk accounting,
//                a tight memory budget, rawRgba, a 10,000-row capture
//                far beyond the GPU texture limit, and cancellation
//
// Every op reports into the tab's status bar (ValueKey 'status-text');
// Run buttons carry ValueKey 'run:<title>'. The journeys drive this UI
// through those keys — keep both stable, they are the test contract.

import 'dart:async';
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;

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

void main() => runApp(const MuralExampleApp());

/// One shared capturer: it remembers the device's learned GPU ceiling,
/// so later captures plan without rediscovery.
final Mural mural = Mural();

class MuralExampleApp extends StatefulWidget {
  const MuralExampleApp({super.key});

  @override
  State<MuralExampleApp> createState() => _MuralExampleAppState();
}

class _MuralExampleAppState extends State<MuralExampleApp> {
  ThemeMode _mode = ThemeMode.light;

  void _toggleTheme() => setState(
    () => _mode = _mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light,
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'mural example',
      themeMode: _mode,
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      darkTheme: ThemeData(
        useMaterial3: true,
        brightness: Brightness.dark,
        colorSchemeSeed: Colors.indigo,
      ),
      home: HomePage(onToggleTheme: _toggleTheme),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key, required this.onToggleTheme});

  final VoidCallback onToggleTheme;

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('mural'),
          actions: [
            // Flip the theme, then re-run "Themed card" on the Offscreen
            // tab: the capture follows the app's CURRENT theme, because
            // capture() inherits the caller context's InheritedThemes.
            IconButton(
              key: const ValueKey('toggle-theme'),
              icon: const Icon(Icons.brightness_6),
              onPressed: onToggleTheme,
              tooltip: 'Toggle theme',
            ),
          ],
          bottom: const TabBar(
            tabs: [
              Tab(text: 'Offscreen'),
              Tab(text: 'On screen'),
              Tab(text: 'Stream'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [_OffscreenTab(), _BoundaryTab(), _StreamTab()],
        ),
      ),
    );
  }
}

// ─── Shared op runner — status bar + cancel wiring, used by every tab ─

mixin _CaptureRunner<T extends StatefulWidget> on State<T> {
  String? status;
  bool running = false;
  MuralTask<Object?>? _inFlight;

  /// Runs one op: status transitions, typed-error handling, and the
  /// status bar's Cancel button while the task is in flight.
  Future<void> runOp(
    String label,
    MuralTask<Object?> Function() start, {
    String Function(Object? result)? describe,
  }) async {
    setState(() {
      running = true;
      status = '$label…';
    });
    final task = start();
    _inFlight = task;
    try {
      final result = await task;
      setState(() => status = describe?.call(result) ?? '✓ $label');
    } on MuralCancelled {
      setState(() => status = '✓ cancelled cleanly (MuralCancelled)');
    } on MuralError catch (e) {
      setState(() => status = 'Error: $e');
    } finally {
      _inFlight = null;
      setState(() => running = false);
    }
  }

  Widget statusBar() => _Status(
    loading: running,
    message: status,
    onCancel: _inFlight == null ? null : () => _inFlight!.cancel(),
    onDismiss: status == null ? null : () => setState(() => status = null),
  );
}

// ─────────────────────────────────────────────────────────────────────
// Offscreen — capture(): widgets that never enter this screen's tree.
// ─────────────────────────────────────────────────────────────────────

class _OffscreenTab extends StatefulWidget {
  const _OffscreenTab();

  @override
  State<_OffscreenTab> createState() => _OffscreenTabState();
}

class _OffscreenTabState extends State<_OffscreenTab>
    with _CaptureRunner, AutomaticKeepAliveClientMixin {
  MuralImage? _shot;

  @override
  bool get wantKeepAlive => true;

  Future<void> _shoot(String label, MuralTask<MuralImage> Function() start) =>
      runOp(
        label,
        start,
        describe: (result) {
          final shot = result! as MuralImage;
          setState(() => _shot = shot);
          return '✓ $label → ${shot.width}×${shot.height}, '
              '${shot.byteCount} bytes';
        },
      );

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(
      children: [
        statusBar(),
        Expanded(
          child: ListView(
            key: const ValueKey('demo-list'),
            padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
            children: [
              const _Section('Pixel ratio'),
              _Op(
                icon: Icons.looks_one,
                title: 'Transcript @1x',
                subtitle: 'A chat transcript that is never on screen',
                onRun: () => _shoot(
                  'Transcript @1x',
                  () => mural.capture(
                    const _TranscriptDemo(messages: 12),
                    context: context,
                    stage: const MuralStage(
                      constraints: BoxConstraints(maxWidth: 360),
                      background: Colors.white,
                    ),
                  ),
                ),
              ),
              _Op(
                icon: Icons.looks_3,
                title: 'Transcript @3x',
                subtitle: 'Same widget, three output pixels per logical',
                onRun: () => _shoot(
                  'Transcript @3x',
                  () => mural.capture(
                    const _TranscriptDemo(messages: 12),
                    context: context,
                    options: const MuralOptions(pixelRatio: 3),
                    stage: const MuralStage(
                      constraints: BoxConstraints(maxWidth: 360),
                      background: Colors.white,
                    ),
                  ),
                ),
              ),
              const _Section('Staging'),
              _Op(
                icon: Icons.straighten,
                title: 'Narrow constraints',
                subtitle: 'maxWidth 220 — the transcript reflows',
                onRun: () => _shoot(
                  'Narrow constraints',
                  () => mural.capture(
                    const _TranscriptDemo(messages: 6),
                    context: context,
                    stage: const MuralStage(
                      constraints: BoxConstraints(maxWidth: 220),
                      background: Colors.white,
                    ),
                  ),
                ),
              ),
              _Op(
                icon: Icons.blur_on,
                title: 'Transparent background',
                subtitle: 'No stage background — checkerboard shows alpha',
                onRun: () => _shoot(
                  'Transparent background',
                  () => mural.capture(
                    const _BadgeDemo(),
                    context: context,
                    options: const MuralOptions(pixelRatio: 2),
                  ),
                ),
              ),
              _Op(
                icon: Icons.palette,
                title: 'Themed card',
                subtitle: 'Follows the app theme — toggle dark and re-run',
                onRun: () => _shoot(
                  'Themed card',
                  () => mural.capture(
                    const _ThemedCardDemo(),
                    context: context,
                    options: const MuralOptions(pixelRatio: 2),
                  ),
                ),
              ),
              _Op(
                icon: Icons.format_textdirection_r_to_l,
                title: 'Right-to-left',
                subtitle: 'Directionality is part of the staged context',
                onRun: () => _shoot(
                  'Right-to-left',
                  () => mural.capture(
                    const Directionality(
                      textDirection: TextDirection.rtl,
                      child: _TranscriptDemo(messages: 4),
                    ),
                    context: context,
                    stage: const MuralStage(
                      constraints: BoxConstraints(maxWidth: 300),
                      background: Colors.white,
                    ),
                  ),
                ),
              ),
              const _Section('Readiness — signals, never timers'),
              _Op(
                icon: Icons.image,
                title: 'Image via precache',
                subtitle: 'ready: (context) => precacheImage(photo, context)',
                onRun: () async {
                  final photo = MemoryImage(await _demoPhotoPng());
                  if (!context.mounted) {
                    return;
                  }
                  await _shoot(
                    'Image via precache',
                    () => mural.capture(
                      Padding(
                        padding: const EdgeInsets.all(8),
                        // fit is explicit: without it Image defaults to
                        // BoxFit.scaleDown, which parks a smaller original in the middle
                        // of the slot instead of filling it.
                        child: Image(
                          image: photo,
                          width: 96,
                          height: 96,
                          fit: BoxFit.fill,
                        ),
                      ),
                      context: context,
                      options: const MuralOptions(pixelRatio: 2),
                      stage: MuralStage(
                        background: Colors.white,
                        ready: (context) => precacheImage(photo, context),
                      ),
                    ),
                  );
                },
              ),
              _Op(
                icon: Icons.hourglass_bottom,
                title: 'Delayed data',
                subtitle: 'The widget loads its own data; ready awaits it',
                onRun: () {
                  // A simulated fetch the WIDGET owns. The capture waits
                  // for the same future — a deterministic signal, not a
                  // guessed delay.
                  final fetch = Future<String>.delayed(
                    const Duration(milliseconds: 300),
                    () => 'Data arrived after 300 ms',
                  );
                  unawaited(
                    _shoot(
                      'Delayed data',
                      () => mural.capture(
                        _ProfileCardDemo(data: fetch),
                        context: context,
                        options: const MuralOptions(pixelRatio: 2),
                        stage: MuralStage(
                          background: Colors.white,
                          ready: (_) => fetch,
                        ),
                      ),
                    ),
                  );
                },
              ),
              const _Section('Typed errors'),
              _Op(
                icon: Icons.crop_free,
                title: 'Empty widget',
                subtitle: 'Lays out to zero size → MuralLayoutError',
                onRun: () => runOp(
                  'Empty widget',
                  () =>
                      mural.capture(const SizedBox.shrink(), context: context),
                ),
              ),
              _Op(
                icon: Icons.all_inclusive,
                title: 'Unbounded widget',
                subtitle: 'Infinite height cannot capture — the error is typed',
                onRun: () => runOp(
                  'Unbounded widget',
                  () => mural.capture(
                    const Column(children: [Spacer(), Text('bottom')]),
                    context: context,
                  ),
                ),
              ),
              _Op(
                icon: Icons.bug_report,
                title: 'Throwing build',
                subtitle: 'The widget throws → MuralBuildError with cause',
                onRun: () => runOp(
                  'Throwing build',
                  () => mural.capture(
                    Builder(builder: (_) => throw StateError('demo boom')),
                    context: context,
                  ),
                ),
              ),
              if (_shot != null) ...[
                const _Section('Result'),
                _ShotCard(
                  bytes: _shot!.bytes,
                  caption:
                      '${_shot!.width}×${_shot!.height} · '
                      '${_shot!.byteCount} bytes',
                ),
              ],
            ],
          ),
        ),
      ],
    );
  }
}

// ─────────────────────────────────────────────────────────────────────
// On screen — captureBoundary(): the frozen moment.
// ─────────────────────────────────────────────────────────────────────

class _BoundaryTab extends StatefulWidget {
  const _BoundaryTab();

  @override
  State<_BoundaryTab> createState() => _BoundaryTabState();
}

class _BoundaryTabState extends State<_BoundaryTab>
    with
        _CaptureRunner,
        AutomaticKeepAliveClientMixin,
        SingleTickerProviderStateMixin {
  final GlobalKey _muralKey = GlobalKey();
  final GlobalKey _plainKey = GlobalKey();
  late final AnimationController _spin = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 3),
  )..repeat();
  final List<MuralImage> _burst = [];
  MuralImage? _shot;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _spin.dispose();
    super.dispose();
  }

  Future<void> _freeze() => runOp(
    'Freeze the moment',
    // The snapshot happens synchronously inside this call — the result
    // is this exact frame, even though the dial keeps spinning while
    // the pixels develop.
    () => mural.captureBoundary(
      _muralKey,
      options: const MuralOptions(pixelRatio: 2),
    ),
    describe: (result) {
      final shot = result! as MuralImage;
      setState(() => _shot = shot);
      return '✓ froze the moment → ${shot.width}×${shot.height}';
    },
  );

  /// Three shots, one per animation third — every trigger the caller
  /// chooses is moment-exact, so a burst is just three calls.
  Future<void> _burstSeries() async {
    setState(() {
      _burst.clear();
      running = true;
      status = 'Burst of three…';
    });
    try {
      for (var i = 0; i < 3; i++) {
        final shot = await mural.captureBoundary(_muralKey);
        setState(() => _burst.add(shot));
        await Future<void>.delayed(const Duration(milliseconds: 350));
      }
      setState(() => status = '✓ burst captured 3 distinct moments');
    } on MuralError catch (e) {
      setState(() => status = 'Error: $e');
    } finally {
      setState(() => running = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(
      children: [
        statusBar(),
        Expanded(
          child: ListView(
            key: const ValueKey('demo-list'),
            padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
            children: [
              const _Section('Live subject'),
              Center(
                child: Padding(
                  padding: const EdgeInsets.all(8),
                  child: MuralBoundary(
                    key: _muralKey,
                    child: SizedBox(
                      width: 120,
                      height: 120,
                      child: AnimatedBuilder(
                        animation: _spin,
                        builder: (_, _) =>
                            CustomPaint(painter: _DialPainter(_spin.value)),
                      ),
                    ),
                  ),
                ),
              ),
              const _Section('Capture'),
              _Op(
                icon: Icons.center_focus_strong,
                title: 'Freeze the moment',
                subtitle: 'Synchronous snapshot — mid-animation, no tearing',
                onRun: _freeze,
              ),
              _Op(
                icon: Icons.burst_mode,
                title: 'Burst of three',
                subtitle: 'Any trigger works: three calls, three moments',
                onRun: _burstSeries,
              ),
              _Op(
                icon: Icons.filter_frames,
                title: 'Plain RepaintBoundary',
                subtitle: 'Also works, within the GPU texture limit',
                onRun: () => runOp(
                  'Plain RepaintBoundary',
                  () => mural.captureBoundary(_plainKey),
                  describe: (result) {
                    final shot = result! as MuralImage;
                    setState(() => _shot = shot);
                    return '✓ plain boundary → ${shot.width}×${shot.height}';
                  },
                ),
              ),
              Center(
                child: RepaintBoundary(
                  key: _plainKey,
                  child: const Padding(
                    padding: EdgeInsets.all(8),
                    child: _BadgeDemo(),
                  ),
                ),
              ),
              const _Section('Typed errors'),
              _Op(
                icon: Icons.link_off,
                title: 'Unmounted key',
                subtitle: 'Nothing to capture → MuralBoundaryError',
                onRun: () => runOp(
                  'Unmounted key',
                  () => mural.captureBoundary(GlobalKey()),
                ),
              ),
              if (_burst.isNotEmpty) ...[
                const _Section('Burst — three distinct moments'),
                Row(
                  children: [
                    for (final shot in _burst)
                      Expanded(
                        child: Padding(
                          padding: const EdgeInsets.all(4),
                          child: _ShotCard(bytes: shot.bytes, caption: ''),
                        ),
                      ),
                  ],
                ),
              ],
              if (_shot != null) ...[
                const _Section('Result'),
                _ShotCard(
                  bytes: _shot!.bytes,
                  caption: '${_shot!.width}×${_shot!.height}',
                ),
              ],
            ],
          ),
        ),
      ],
    );
  }
}

// ─────────────────────────────────────────────────────────────────────
// Stream — captureInto(): progress, budgets, formats, cancellation.
// ─────────────────────────────────────────────────────────────────────

class _StreamTab extends StatefulWidget {
  const _StreamTab();

  @override
  State<_StreamTab> createState() => _StreamTabState();
}

class _StreamTabState extends State<_StreamTab>
    with _CaptureRunner, AutomaticKeepAliveClientMixin {
  double _fraction = 0;
  String _phase = '';
  Uint8List? _png;
  String? _caption;

  @override
  bool get wantKeepAlive => true;

  void _watch(MuralTask<Object?> task) {
    task.progress.listen((p) {
      setState(() {
        _fraction = p.fraction;
        _phase = switch (p) {
          MuralLayoutProgress() => 'laying out',
          MuralCaptureProgress(:final tilesDone, :final tileCount) =>
            'capturing tile $tilesDone/$tileCount',
          MuralEncodeProgress(:final bytesEmitted) =>
            'encoded $bytesEmitted bytes',
        };
      });
    });
  }

  Future<void> _streamPoster() async {
    final chunks = <List<int>>[];
    await runOp(
      'Stream the poster',
      () {
        final task = mural.captureInto(
          _ChunkSink(chunks),
          const _PosterDemo(),
          context: context,
          // A deliberately tight budget: many bands, identical pixels.
          options: const MuralOptions(pixelRatio: 2, memoryLimitBytes: 1 << 20),
        );
        _watch(task);
        return task;
      },
      describe: (result) {
        final info = result! as MuralImageInfo;
        final assembled = BytesBuilder(copy: false);
        for (final chunk in chunks) {
          assembled.add(chunk);
        }
        setState(() {
          _png = assembled.takeBytes();
          _caption = '${info.width}×${info.height}';
        });
        return '✓ streamed ${chunks.length} chunks, ${info.byteCount} bytes '
            '(${info.width}×${info.height})';
      },
    );
  }

  Future<void> _streamHuge() => runOp(
    '10,000-row capture',
    () {
      // 10,000 rows ≈ 200,000 output pixels tall — far beyond any GPU
      // texture limit, streamed into a counting sink so the image never
      // sits in memory.
      final task = mural.captureInto(
        _CountingSink(),
        const _BigListDemo(rows: 10000),
        context: context,
        options: const MuralOptions(memoryLimitBytes: 8 << 20),
        stage: const MuralStage(constraints: BoxConstraints(maxWidth: 240)),
      );
      _watch(task);
      return task;
    },
    describe: (result) {
      final info = result! as MuralImageInfo;
      return '✓ huge: ${info.width}×${info.height} '
          '(${info.byteCount} bytes) — beyond any texture limit';
    },
  );

  Future<void> _rawRgba() => runOp(
    'Raw straight RGBA',
    () => mural.capture(
      const _BadgeDemo(),
      context: context,
      options: const MuralOptions(format: MuralFormat.rawRgba),
    ),
    describe: (result) {
      final shot = result! as MuralImage;
      return '✓ rawRgba: ${shot.width}×${shot.height} = '
          '${shot.bytes.length} bytes (w×h×4), straight alpha';
    },
  );

  Future<void> _cancelable() => runOp('Start, then cancel', () {
    final task = mural.captureInto(
      _CountingSink(),
      const _BigListDemo(rows: 10000),
      context: context,
      options: const MuralOptions(memoryLimitBytes: 1 << 20),
      stage: const MuralStage(constraints: BoxConstraints(maxWidth: 240)),
    );
    _watch(task);
    return task;
  });

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(
      children: [
        statusBar(),
        Expanded(
          child: ListView(
            key: const ValueKey('demo-list'),
            padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
            children: [
              const _Section('Progress'),
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    LinearProgressIndicator(value: _fraction),
                    const SizedBox(height: 4),
                    Text(_phase, style: const TextStyle(fontSize: 12)),
                  ],
                ),
              ),
              const _Section('Stream'),
              _Op(
                icon: Icons.water,
                title: 'Stream the poster',
                subtitle: '1 MB working budget — bands flow to a sink',
                onRun: _streamPoster,
              ),
              _Op(
                icon: Icons.unfold_more,
                title: '10,000-row capture',
                subtitle: 'Far beyond the GPU texture limit (#118024)',
                onRun: _streamHuge,
              ),
              _Op(
                icon: Icons.cancel_schedule_send,
                title: 'Start, then cancel',
                subtitle: 'Tap Cancel in the status bar → MuralCancelled',
                onRun: _cancelable,
              ),
              const _Section('Formats'),
              _Op(
                icon: Icons.grain,
                title: 'Raw straight RGBA',
                subtitle: 'The escape hatch into your own encoder',
                onRun: _rawRgba,
              ),
              if (_png != null) ...[
                const _Section('Result'),
                _ShotCard(bytes: _png!, caption: _caption ?? ''),
              ],
            ],
          ),
        ),
      ],
    );
  }
}

// ─────────────────────────────────────────────────────────────────────
// Demo subjects — the widgets that get captured.
// ─────────────────────────────────────────────────────────────────────

/// A chat transcript. Never on screen in the Offscreen demos.
class _TranscriptDemo extends StatelessWidget {
  const _TranscriptDemo({required this.messages});

  final int messages;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        for (var i = 0; i < messages; i++)
          Align(
            // Directional on purpose: under RTL the whole transcript
            // mirrors — incoming bubbles jump to the right, outgoing to
            // the left — which is what the Right-to-left op demonstrates.
            alignment: i.isEven
                ? AlignmentDirectional.centerStart
                : AlignmentDirectional.centerEnd,
            child: Container(
              margin: const EdgeInsets.all(6),
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: i.isEven ? Colors.indigo.shade100 : Colors.indigo,
                // The flat "tail" corner hugs the speaker's side, so the
                // mirror is visible per bubble, not just per row.
                borderRadius: i.isEven
                    ? const BorderRadiusDirectional.only(
                        topStart: Radius.circular(2),
                        topEnd: Radius.circular(12),
                        bottomStart: Radius.circular(12),
                        bottomEnd: Radius.circular(12),
                      )
                    : const BorderRadiusDirectional.only(
                        topStart: Radius.circular(12),
                        topEnd: Radius.circular(2),
                        bottomStart: Radius.circular(12),
                        bottomEnd: Radius.circular(12),
                      ),
              ),
              child: Text(
                'Message ${i + 1} of the transcript',
                style: TextStyle(
                  color: i.isEven ? Colors.black87 : Colors.white,
                ),
              ),
            ),
          ),
      ],
    );
  }
}

/// A round badge over nothing — captured without a stage background its
/// corners stay transparent, which the checkerboard viewer proves.
class _BadgeDemo extends StatelessWidget {
  const _BadgeDemo();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 96,
      height: 96,
      decoration: const BoxDecoration(
        shape: BoxShape.circle,
        gradient: LinearGradient(colors: [Colors.deepOrange, Colors.pink]),
      ),
      alignment: Alignment.center,
      child: const Text(
        'mural',
        style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
      ),
    );
  }
}

/// Built from the ambient theme — Card, colors, and text styles come
/// from whatever theme the CALLER's context carries at capture time.
class _ThemedCardDemo extends StatelessWidget {
  const _ThemedCardDemo();

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    // Material components (ListTile, InkWell…) need a Material
    // ancestor. On screen a Scaffold provides one; an offscreen tree
    // has no Scaffold, so the captured widget brings its own.
    return Material(
      color: cs.surface,
      child: SizedBox(
        width: 260,
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Card(
            child: ListTile(
              leading: Icon(Icons.palette, color: cs.primary),
              title: const Text('Themed card'),
              subtitle: Text(
                Theme.of(context).brightness == Brightness.dark
                    ? 'captured in dark'
                    : 'captured in light',
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// A widget that loads its own data — the delayed-ready scenario. The
/// first frame shows the placeholder; the ready signal (the same
/// future) gates the capture until the data frame.
class _ProfileCardDemo extends StatelessWidget {
  const _ProfileCardDemo({required this.data});

  final Future<String> data;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 260,
      padding: const EdgeInsets.all(16),
      child: FutureBuilder<String>(
        future: data,
        builder: (_, snap) => Row(
          children: [
            const Icon(Icons.person, size: 32),
            const SizedBox(width: 8),
            Expanded(
              child: Text(
                snap.data ?? 'loading…',
                style: const TextStyle(color: Colors.black87),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// A poster-sized gradient grid — enough rows to force many bands under
/// the Stream demo's 1 MB budget.
class _PosterDemo extends StatelessWidget {
  const _PosterDemo();

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 400,
      height: 1200,
      child: CustomPaint(painter: _PosterPainter()),
    );
  }
}

class _PosterPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const rows = 24;
    const columns = 8;
    final cell = Size(size.width / columns, size.height / rows);
    for (var y = 0; y < rows; y++) {
      for (var x = 0; x < columns; x++) {
        canvas.drawRect(
          Offset(x * cell.width, y * cell.height) & cell,
          Paint()
            ..color = HSVColor.fromAHSV(
              1,
              (x * 45 + y * 15) % 360,
              0.6,
              0.95,
            ).toColor(),
        );
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

/// Ten thousand rows, painted cheaply — the flutter#118024 case: output
/// far taller than any GPU texture.
class _BigListDemo extends StatelessWidget {
  const _BigListDemo({required this.rows});

  final int rows;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 240,
      height: rows * 20,
      child: CustomPaint(painter: _RowsPainter(rows)),
    );
  }
}

class _RowsPainter extends CustomPainter {
  _RowsPainter(this.rows);

  final int rows;

  @override
  void paint(Canvas canvas, Size size) {
    final rowHeight = size.height / rows;
    for (var i = 0; i < rows; i++) {
      canvas.drawRect(
        Rect.fromLTWH(0, i * rowHeight, size.width, rowHeight),
        Paint()..color = i.isEven ? Colors.indigo.shade50 : Colors.white,
      );
      canvas.drawCircle(
        Offset(12, i * rowHeight + rowHeight / 2),
        4,
        Paint()..color = Colors.indigo,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

/// The spinning dial the On-screen demos capture mid-flight.
class _DialPainter extends CustomPainter {
  _DialPainter(this.turns);

  final double turns;

  @override
  void paint(Canvas canvas, Size size) {
    final center = size.center(Offset.zero);
    final radius = size.shortestSide / 2;
    canvas.drawCircle(center, radius, Paint()..color = Colors.indigo.shade50);
    canvas.drawCircle(
      center,
      radius,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 4
        ..color = Colors.indigo,
    );
    final angle = turns * 2 * math.pi;
    canvas.drawLine(
      center,
      center + Offset.fromDirection(angle - math.pi / 2, radius * 0.8),
      Paint()
        ..strokeWidth = 5
        ..strokeCap = StrokeCap.round
        ..color = Colors.deepOrange,
    );
  }

  @override
  bool shouldRepaint(covariant _DialPainter oldDelegate) =>
      oldDelegate.turns != turns;
}

// ─────────────────────────────────────────────────────────────────────
// Chrome — the shared design system (status bar, sections, op cards,
// the checkerboard shot viewer).
// ─────────────────────────────────────────────────────────────────────

class _Status extends StatelessWidget {
  const _Status({
    required this.loading,
    this.message,
    this.onDismiss,
    this.onCancel,
  });

  final bool loading;
  final String? message;
  final VoidCallback? onDismiss;
  final VoidCallback? onCancel;

  @override
  Widget build(BuildContext context) {
    if (!loading && message == null) {
      return const SizedBox.shrink();
    }
    final cs = Theme.of(context).colorScheme;
    final isError = message?.startsWith('Error') ?? false;
    final bg = loading
        ? cs.primaryContainer
        : (isError ? cs.errorContainer : cs.tertiaryContainer);
    final fg = loading
        ? cs.onPrimaryContainer
        : (isError ? cs.onErrorContainer : cs.onTertiaryContainer);
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      color: bg,
      child: Row(
        children: [
          if (loading) ...[
            SizedBox(
              width: 14,
              height: 14,
              child: CircularProgressIndicator(strokeWidth: 2, color: fg),
            ),
            const SizedBox(width: 12),
          ],
          Expanded(
            child: Text(
              message ?? '',
              key: const ValueKey('status-text'),
              style: TextStyle(color: fg, fontSize: 13),
              maxLines: 4,
            ),
          ),
          if (loading && onCancel != null)
            TextButton.icon(
              onPressed: onCancel,
              icon: Icon(Icons.cancel, size: 16, color: fg),
              label: Text('Cancel', style: TextStyle(color: fg, fontSize: 12)),
            ),
          if (!loading && onDismiss != null)
            GestureDetector(
              onTap: onDismiss,
              child: Icon(Icons.close, size: 16, color: fg),
            ),
        ],
      ),
    );
  }
}

class _Section extends StatelessWidget {
  const _Section(this.text);

  final String text;

  @override
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.only(top: 14, bottom: 4, left: 4),
    child: Text(
      text.toUpperCase(),
      style: TextStyle(
        fontSize: 11,
        fontWeight: FontWeight.w700,
        letterSpacing: 1.2,
        color: Theme.of(context).colorScheme.primary,
      ),
    ),
  );
}

class _Op extends StatelessWidget {
  const _Op({
    required this.icon,
    required this.title,
    this.subtitle,
    required this.onRun,
  });

  final IconData icon;
  final String title;
  final String? subtitle;
  final VoidCallback onRun;

  @override
  Widget build(BuildContext context) => Card(
    margin: const EdgeInsets.symmetric(vertical: 2),
    child: ListTile(
      leading: Icon(icon, size: 22),
      title: Text(
        title,
        style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
      ),
      subtitle: subtitle != null
          ? Text(subtitle!, style: const TextStyle(fontSize: 12))
          : null,
      trailing: SizedBox(
        width: 64,
        height: 34,
        child: FilledButton.tonal(
          // Stable handle for the journeys ('run:<title>').
          key: ValueKey('run:$title'),
          onPressed: onRun,
          style: FilledButton.styleFrom(
            padding: EdgeInsets.zero,
            textStyle: const TextStyle(fontSize: 13),
          ),
          child: const Text('Run'),
        ),
      ),
      dense: true,
      visualDensity: VisualDensity.compact,
    ),
  );
}

/// A captured result on a checkerboard (so transparency is visible),
/// with its caption. Tap to inspect full-screen with pan + zoom.
class _ShotCard extends StatelessWidget {
  const _ShotCard({required this.bytes, required this.caption});

  final Uint8List bytes;
  final String caption;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        GestureDetector(
          onTap: () => showDialog<void>(
            context: context,
            builder: (_) => Dialog(
              child: InteractiveViewer(
                maxScale: 8,
                child: CustomPaint(
                  painter: _CheckerboardPainter(),
                  child: Image.memory(bytes),
                ),
              ),
            ),
          ),
          child: Container(
            decoration: BoxDecoration(
              border: Border.all(
                color: Theme.of(context).colorScheme.outlineVariant,
              ),
              borderRadius: BorderRadius.circular(8),
            ),
            clipBehavior: Clip.antiAlias,
            constraints: const BoxConstraints(maxHeight: 280),
            child: CustomPaint(
              painter: _CheckerboardPainter(),
              // cacheWidth keeps huge captures cheap to display.
              child: Image.memory(bytes, cacheWidth: 720, fit: BoxFit.contain),
            ),
          ),
        ),
        const SizedBox(height: 4),
        Text(caption, style: Theme.of(context).textTheme.labelSmall),
      ],
    );
  }
}

class _CheckerboardPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    const cell = 8.0;
    final light = Paint()..color = const Color(0xFFEEEEEE);
    final dark = Paint()..color = const Color(0xFFCCCCCC);
    for (var y = 0.0; y < size.height; y += cell) {
      for (var x = 0.0; x < size.width; x += cell) {
        canvas.drawRect(
          Rect.fromLTWH(x, y, cell, cell),
          ((x / cell + y / cell) % 2 == 0) ? light : dark,
        );
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

// ─── Sinks ───────────────────────────────────────────────────────────

final class _ChunkSink implements Sink<List<int>> {
  _ChunkSink(this.chunks);

  final List<List<int>> chunks;

  @override
  void add(List<int> data) => chunks.add(data);

  @override
  void close() {}
}

/// Counts bytes and drops them — the 10,000-row capture's destination,
/// proving the image never has to exist anywhere.
final class _CountingSink implements Sink<List<int>> {
  int total = 0;

  @override
  void add(List<int> data) => total += data.length;

  @override
  void close() {}
}

/// A small photo for the precache demo, generated through the engine so
/// the bytes are always a valid PNG: draw → rasterize → encode.
/// MemoryImage then decodes it through the real async image pipeline —
/// exactly what the ready recipe waits for.
Future<Uint8List> _demoPhotoPng() async {
  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder, const Rect.fromLTWH(0, 0, 96, 96));
  canvas.drawRect(
    const Rect.fromLTWH(0, 0, 96, 96),
    Paint()..color = Colors.deepOrange,
  );
  canvas.drawCircle(const Offset(48, 48), 30, Paint()..color = Colors.white);
  final picture = recorder.endRecording();
  final image = await picture.toImage(96, 96);
  picture.dispose();
  try {
    final data = await image.toByteData(format: ui.ImageByteFormat.png);
    return data!.buffer.asUint8List();
  } finally {
    image.dispose();
  }
}
1
likes
0
points
125
downloads

Publisher

verified publisherwhuppi.com

Weekly Downloads

Capture any Flutter widget as an image, at any size. Screenshot huge lists, full pages, and even unseen widgets — past the GPU texture limit, always crisp, in constant memory.

Repository (GitHub)
View/report issues

Topics

#screenshot #widget-to-image #capture #image #cross-platform

License

unknown (license)

Dependencies

flutter

More

Packages that depend on mural