E-commerce72 views

How to Build a Wishlist Share Bottom Sheet in Flutter (Full Code + Preview)

Sharing a wishlist usually means handing someone a link, but in person the fastest handoff is a camera pointed at a QR code. This tutorial builds StyleCart's share sheet in Flutter: a bottom sheet over a tappable `0x66000000` scrim, a QR card painted entirely by `_QrPainter` from a deterministic bit formula, a copy-link row whose `FilledButton` flips from coral 'Copy' to ink 'Copied' via `_copied`, an 'Allow others to add items' `Switch` bound to `_allowAdds`, and four tinted channel tiles that report their label through `onChannel`. No pub packages, no network, no image assets.

Share Wishlist — E-commerce Flutter UI screen
Live preview — Share Wishlist, built in pure Flutter.

Watch the Flutter UI walkthrough

A short screen recording of Share Wishlist running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.

Can't see the video? Watch it on YouTube.

What you'll build

  • ✓A dismissable bottom sheet layered over a 40% black scrim with a `Positioned.fill` `GestureDetector` wired to `onClose`
  • ✓A 176px QR card drawn by `_QrPainter` — three rounded finder squares, a hash-driven 21×21 module grid and a heart-red centre dot
  • ✓A copy-link row whose button swaps colour and label the moment `_copied` becomes true
  • ✓A collaborator `Switch` styled with a coral track and transparent outline, held in `_allowAdds`
  • ✓A four-up channel row built from a `static const List<_Channel>` with 10% tinted icon tiles

Step-by-step build

1

Create the file

Add a new file at lib/ecom_wishlist_share/ecom_wishlist_share_screen.dart in your Flutter project.

2

Register the bundled fonts

No external packages — this is pure Flutter. It does bundle its design font (Manrope), so drop the font file into fonts/ and declare it in pubspec.yaml:

pubspec.yaml
flutter:
  fonts:
    - family: Manrope
      fonts:
        - asset: fonts/Manrope-Regular.ttf
3

Build it, piece by piece

Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.

Callbacks, tokens and the channel table

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

/// StyleCart — Share Wishlist.
///
/// A dimmed-scrim bottom sheet to share a wishlist board: a painted QR card a
/// friend can scan, a copy-link row, and a row of share channels. Toggles let
/// the owner allow others to add items or keep the board view-only.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. The QR is a CustomPainter
/// (deterministic module grid — no emoji glyph, no network). Exposes callbacks
/// only.
class EcomWishlistShareScreen extends StatefulWidget {
  const EcomWishlistShareScreen({
    super.key,
    this.onClose,
    this.onCopyLink,
    this.onChannel,
  });

  final VoidCallback? onClose;
  final VoidCallback? onCopyLink;
  final ValueChanged<String>? onChannel;

  @override
  State<EcomWishlistShareScreen> createState() =>
      _EcomWishlistShareScreenState();
}

class _EcomWishlistShareScreenState extends State<EcomWishlistShareScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const String _link = 'rohansurve.in/w/wedding-guest';

  bool _allowAdds = true;
  bool _copied = false;

  static const List<_Channel> _channels = <_Channel>[
    _Channel('Messages', Icons.sms_rounded, Color(0xFF2E9E5B)),
    _Channel('Mail', Icons.mail_rounded, Color(0xFFF5A623)),
    _Channel('Copy', Icons.link_rounded, Color(0xFF222222)),
    _Channel('More', Icons.more_horiz_rounded, Color(0xFF6A6A6A)),
  ];

`EcomWishlistShareScreen` is a `StatefulWidget` exposing exactly three callbacks: `onClose` for the scrim tap, `onCopyLink` for the copy button, and `onChannel`, a `ValueChanged<String>` that hands back the tapped channel's label rather than an index, so the host can switch on 'Messages' or 'Mail' without knowing tile order. The state class carries Airbnb-style tokens — `_ink` `#222222`, `_muted` `#6A6A6A`, `_faint` `#C1C1C1` for the off switch track, coral `_brand` `#FF385C`, `_surface` `#F2F2F2` for the link pill and `_hairline` `#EBEBEB`. The shareable URL is a `static const _link` string and two mutable flags drive the UI: `_allowAdds` (defaults to true) and `_copied` (false). The four channels are a `static const List<_Channel>` pairing a label, an `Icons.*_rounded` glyph and a tint — green for Messages, amber for Mail, ink for Copy, muted grey for More — so adding WhatsApp later is a one-line data change.

Scrim, stack and a bottom-aligned sheet

ecom_wishlist_share_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: Colors.transparent,
        body: Stack(
          children: <Widget>[
            // Dimmed scrim — tap to dismiss.
            Positioned.fill(
              child: GestureDetector(
                onTap: widget.onClose,
                child: const ColoredBox(color: Color(0x66000000)),
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: _sheet(),
            ),
          ],
        ),
      ),
    );
  }

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the sheet renders identically regardless of the host app's theme. The `Scaffold` gets `backgroundColor: Colors.transparent`, which matters because the widget is meant to sit on top of whatever screen opened it; a default white scaffold would hide it. Inside a `Stack`, `Positioned.fill` holds a `GestureDetector` around a `ColoredBox` of `0x66000000` — 40% black — and its `onTap` is simply `widget.onClose`, so tapping anywhere outside the sheet dismisses it without the sheet needing its own close button. `Align(alignment: Alignment.bottomCenter)` then pins `_sheet()` to the bottom edge, leaving the top of the screen as scrim.

The sheet body: handle, title and section stack

ecom_wishlist_share_screen.dart
  Widget _sheet() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
      ),
      child: SafeArea(
        top: false,
        child: SingleChildScrollView(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Container(
                  width: 40,
                  height: 4,
                  decoration: BoxDecoration(
                    color: _hairline,
                    borderRadius: BorderRadius.circular(2),
                  ),
                ),
                const SizedBox(height: 18),
                const Text(
                  'Share “Wedding guest”',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 19,
                    fontWeight: FontWeight.w800,
                    letterSpacing: -0.3,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 4),
                const Text(
                  '12 items · anyone with the link can view',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 13,
                    fontWeight: FontWeight.w500,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 20),
                _qrCard(),
                const SizedBox(height: 18),
                _linkRow(),
                const SizedBox(height: 16),
                _allowRow(),
                const SizedBox(height: 18),
                _channelRow(),
              ],
            ),
          ),
        ),
      ),
    );
  }

`_sheet` is a white `Container` with `BorderRadius.vertical(top: Radius.circular(28))`, so only the top corners round — the bottom sits flush with the device edge. `SafeArea(top: false)` is placed inside the container rather than outside, which keeps the white background extending under the home indicator while padding the content above it. A `SingleChildScrollView` guards against short phones, and the `Column` uses `mainAxisSize: MainAxisSize.min` so the sheet hugs its content instead of filling the screen. The 40×4 grab handle is a `_hairline` pill with radius 2, followed by the 19px `w800` title 'Share “Wedding guest”' with `letterSpacing: -0.3` and a 13px muted line stating the item count and current visibility. The four sections — `_qrCard`, `_linkRow`, `_allowRow`, `_channelRow` — are separated by 18/16/18px gaps, slightly tighter around the toggle to group it with the link.

The QR card container

ecom_wishlist_share_screen.dart
  Widget _qrCard() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(20),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          SizedBox(
            width: 176,
            height: 176,
            child: CustomPaint(painter: const _QrPainter()),
          ),
          const SizedBox(height: 14),
          const Text(
            'Scan to open the board',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w600,
              color: _ink,
            ),
          ),
          const SizedBox(height: 2),
          const Text(
            'Point any camera here',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12,
              fontWeight: FontWeight.w500,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

`_qrCard` is a bordered white panel (radius 20, `_hairline` border, 20px padding) rather than a filled grey one, because the QR needs maximum contrast around it to scan reliably — a tinted background would reduce the quiet zone. The code itself is a fixed `SizedBox(width: 176, height: 176)` around `CustomPaint(painter: const _QrPainter())`; the painter is `const` because it takes no parameters, so Flutter can reuse the same instance every rebuild. Fixed square dimensions matter here: the painter divides `size.width` by 21 to get its module size, so a non-square box would produce stretched modules. Two captions follow — 'Scan to open the board' at 13.5px `w600` in ink and 'Point any camera here' at 12px muted — spaced only 2px apart so they read as one instruction.

Copy-link row with a Copied state

ecom_wishlist_share_screen.dart
  Widget _linkRow() {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 6, 6, 6),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.link_rounded, size: 18, color: _muted),
          const SizedBox(width: 10),
          const Expanded(
            child: Text(
              _link,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w600,
                color: _ink,
              ),
            ),
          ),
          const SizedBox(width: 8),
          SizedBox(
            height: 40,
            child: FilledButton(
              onPressed: () {
                setState(() => _copied = true);
                widget.onCopyLink?.call();
              },
              style: FilledButton.styleFrom(
                backgroundColor: _copied ? _ink : _brand,
                foregroundColor: _canvas,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(10),
                ),
              ),
              child: Text(
                _copied ? 'Copied' : 'Copy',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 13.5,
                  fontWeight: FontWeight.w700,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

The link row is a `_surface` pill with asymmetric padding `fromLTRB(16, 6, 6, 6)` — 16px on the left for the text, 6px on the right so the button sits nearly flush inside the pill. A link icon, then the URL in an `Expanded` `Text` with `maxLines: 1` and `TextOverflow.ellipsis` so a long slug truncates rather than pushing the button off-screen. The 40px-tall `FilledButton` is where the state lives: its `onPressed` calls `setState(() => _copied = true)` and then `widget.onCopyLink?.call()`, so the host does the actual clipboard write while the widget handles feedback. `backgroundColor` reads `_copied ? _ink : _brand` and the label reads `_copied ? 'Copied' : 'Copy'`, so one boolean drives both the colour shift from coral to near-black and the text. Note `_copied` never resets — the sheet is short-lived, so once copied it stays confirmed.

The collaborator toggle

ecom_wishlist_share_screen.dart
  Widget _allowRow() {
    return Row(
      children: <Widget>[
        Container(
          width: 38,
          height: 38,
          decoration: BoxDecoration(
            color: _surface,
            borderRadius: BorderRadius.circular(11),
          ),
          child: const Icon(Icons.group_add_rounded, size: 19, color: _ink),
        ),
        const SizedBox(width: 12),
        const Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                'Allow others to add items',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 14,
                  fontWeight: FontWeight.w700,
                  color: _ink,
                ),
              ),
              SizedBox(height: 1),
              Text(
                'Collaborators can save into this board',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12,
                  fontWeight: FontWeight.w500,
                  color: _muted,
                ),
              ),
            ],
          ),
        ),
        Switch(
          value: _allowAdds,
          onChanged: (bool v) => setState(() => _allowAdds = v),
          activeThumbColor: _canvas,
          activeTrackColor: _brand,
          inactiveThumbColor: _canvas,
          inactiveTrackColor: _faint,
          trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
        ),
      ],
    );
  }

`_allowRow` is a three-part `Row`: a 38×38 `_surface` tile (radius 11) holding a 19px `Icons.group_add_rounded`, an `Expanded` two-line label column, and a `Switch`. The title 'Allow others to add items' sits at 14px `w700` and the description 'Collaborators can save into this board' at 12px muted, with just 1px between them so they stack tightly. The `Switch` is fully restyled: `activeTrackColor: _brand` gives the on state a coral track, `inactiveTrackColor: _faint` uses the light grey `#C1C1C1`, both thumbs stay white via `activeThumbColor` and `inactiveThumbColor`, and `trackOutlineColor: WidgetStateProperty.all(Colors.transparent)` removes the Material 3 track border, which would otherwise draw a dark ring around the grey track. `onChanged` just writes `_allowAdds` through `setState`; there is no callback for it, so a host that needs to persist the value should add one alongside `onCopyLink`.

Four channel tiles from one data list

ecom_wishlist_share_screen.dart
  Widget _channelRow() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: <Widget>[
        for (final _Channel c in _channels)
          Expanded(
            child: GestureDetector(
              onTap: () => widget.onChannel?.call(c.label),
              behavior: HitTestBehavior.opaque,
              child: Column(
                children: <Widget>[
                  Container(
                    width: 54,
                    height: 54,
                    decoration: BoxDecoration(
                      color: c.tint.withValues(alpha: 0.10),
                      borderRadius: BorderRadius.circular(16),
                    ),
                    child: Icon(c.icon, size: 23, color: c.tint),
                  ),
                  const SizedBox(height: 7),
                  Text(
                    c.label,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                      color: _ink,
                    ),
                  ),
                ],
              ),
            ),
          ),
      ],
    );
  }
}

class _Channel {
  const _Channel(this.label, this.icon, this.tint);
  final String label;
  final IconData icon;
  final Color tint;
}

`_channelRow` builds its tiles with a collection-for over `_channels`, each wrapped in `Expanded` so the four take equal width and `mainAxisAlignment.spaceBetween` becomes a no-op safety net. Each tile is a `GestureDetector` with `behavior: HitTestBehavior.opaque`, which is essential: without it, taps on the empty space between the icon and its label would fall through to the scrim and dismiss the sheet. The `onTap` fires `widget.onChannel?.call(c.label)`. The icon sits in a 54×54 box (radius 16) filled with `c.tint.withValues(alpha: 0.10)` — the same colour at 10% — so Messages gets a pale green tile with a green icon, Mail pale amber, and so on, the two-layer accent pattern that keeps tiles distinct without heavy colour. Labels are 12px `w600` ink. The `_Channel` class is a three-field const holder — `label`, `icon`, `tint` — which is what lets the whole list be `static const`.

Painting a stylised QR code

ecom_wishlist_share_screen.dart
/// Paints a stylised QR code: three finder squares plus a deterministic module
/// grid driven by a fixed bit table (no randomness, no network) so it renders
/// identically every frame and in the golden harness.
class _QrPainter extends CustomPainter {
  const _QrPainter();

  // 21×21 module field. Finder patterns are drawn separately; the data area is
  // filled from this hand-authored bit table so the result looks like a real
  // QR without encoding anything scannable.
  static const int _n = 21;

  @override
  void paint(Canvas canvas, Size size) {
    final double m = size.width / _n; // module size
    final Paint dark = Paint()..color = const Color(0xFF222222);

    // Quiet-zone background.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(0, 0, size.width, size.height),
        const Radius.circular(8),
      ),
      Paint()..color = const Color(0xFFFFFFFF),
    );

    // Data modules: a fixed pseudo-pattern (avoid the finder corners).
    for (int y = 0; y < _n; y++) {
      for (int x = 0; x < _n; x++) {
        if (_inFinder(x, y)) continue;
        // Deterministic checker-ish hash — stable across frames.
        final int h = (x * 7 + y * 13 + x * y * 3) % 5;
        if (h == 0 || h == 2) {
          canvas.drawRect(
            Rect.fromLTWH(x * m + m * 0.08, y * m + m * 0.08, m * 0.84, m * 0.84),
            dark,
          );
        }
      }
    }

    // Three finder patterns (top-left, top-right, bottom-left).
    _finder(canvas, 0, 0, m, dark);
    _finder(canvas, (_n - 7) * m, 0, m, dark);
    _finder(canvas, 0, (_n - 7) * m, m, dark);

    // Brand centre dot — the StyleCart heart accent inside the QR.
    final double cx = size.width / 2;
    final double cy = size.height / 2;
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromCenter(center: Offset(cx, cy), width: m * 4.4, height: m * 4.4),
        Radius.circular(m),
      ),
      Paint()..color = const Color(0xFFFFFFFF),
    );
    canvas.drawCircle(Offset(cx, cy), m * 1.4, Paint()..color = _brandRed);
  }

  static const Color _brandRed = Color(0xFFFF385C);

  bool _inFinder(int x, int y) {
    const int f = 8; // 7-module finder + 1 separator
    final bool tl = x < f && y < f;
    final bool tr = x >= _n - f && y < f;
    final bool bl = x < f && y >= _n - f;
    return tl || tr || bl;
  }

  void _finder(Canvas canvas, double left, double top, double m, Paint dark) {
    // Outer 7×7 ring.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(left, top, m * 7, m * 7),
        Radius.circular(m * 1.6),
      ),
      dark,
    );
    // Punch the white gap.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(left + m, top + m, m * 5, m * 5),
        Radius.circular(m * 1.1),
      ),
      Paint()..color = const Color(0xFFFFFFFF),
    );
    // Solid inner 3×3.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(left + m * 2, top + m * 2, m * 3, m * 3),
        Radius.circular(m * 0.8),
      ),
      dark,
    );
  }

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

`_QrPainter` fakes a QR convincingly without encoding data. The module size is `size.width / 21`. After painting a white rounded quiet zone it loops a 21×21 grid, skipping any cell where `_inFinder` is true (an 8-module square in three corners — 7 for the finder plus 1 separator). Remaining cells are filled when `(x * 7 + y * 13 + x * y * 3) % 5` equals 0 or 2, giving roughly 40% density with a pseudo-random look that is fully deterministic — identical every frame and in golden tests. Each module is inset 8% per side (`m * 0.84` wide) so dots stay separate. `_finder` stacks three `RRect`s per corner: a 7×7 dark ring, a 5×5 white punch, then a solid 3×3 core, radii scaling from `m * 1.6` down to `m * 0.8`. Finally a white `m * 4.4` square is cleared in the centre and a coral circle of radius `m * 1.4` becomes the brand mark. `shouldRepaint` returns false since nothing changes.

Full code

The complete, ready-to-paste source. Free to use in your projects — one click copies it all.

import 'package:flutter/material.dart';

/// StyleCart — Share Wishlist.
///
/// A dimmed-scrim bottom sheet to share a wishlist board: a painted QR card a
/// friend can scan, a copy-link row, and a row of share channels. Toggles let
/// the owner allow others to add items or keep the board view-only.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. The QR is a CustomPainter
/// (deterministic module grid — no emoji glyph, no network). Exposes callbacks
/// only.
class EcomWishlistShareScreen extends StatefulWidget {
  const EcomWishlistShareScreen({
    super.key,
    this.onClose,
    this.onCopyLink,
    this.onChannel,
  });

  final VoidCallback? onClose;
  final VoidCallback? onCopyLink;
  final ValueChanged<String>? onChannel;

  @override
  State<EcomWishlistShareScreen> createState() =>
      _EcomWishlistShareScreenState();
}

class _EcomWishlistShareScreenState extends State<EcomWishlistShareScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const String _link = 'rohansurve.in/w/wedding-guest';

  bool _allowAdds = true;
  bool _copied = false;

  static const List<_Channel> _channels = <_Channel>[
    _Channel('Messages', Icons.sms_rounded, Color(0xFF2E9E5B)),
    _Channel('Mail', Icons.mail_rounded, Color(0xFFF5A623)),
    _Channel('Copy', Icons.link_rounded, Color(0xFF222222)),
    _Channel('More', Icons.more_horiz_rounded, Color(0xFF6A6A6A)),
  ];

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: Colors.transparent,
        body: Stack(
          children: <Widget>[
            // Dimmed scrim — tap to dismiss.
            Positioned.fill(
              child: GestureDetector(
                onTap: widget.onClose,
                child: const ColoredBox(color: Color(0x66000000)),
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: _sheet(),
            ),
          ],
        ),
      ),
    );
  }

  Widget _sheet() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
      ),
      child: SafeArea(
        top: false,
        child: SingleChildScrollView(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                Container(
                  width: 40,
                  height: 4,
                  decoration: BoxDecoration(
                    color: _hairline,
                    borderRadius: BorderRadius.circular(2),
                  ),
                ),
                const SizedBox(height: 18),
                const Text(
                  'Share “Wedding guest”',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 19,
                    fontWeight: FontWeight.w800,
                    letterSpacing: -0.3,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 4),
                const Text(
                  '12 items · anyone with the link can view',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 13,
                    fontWeight: FontWeight.w500,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 20),
                _qrCard(),
                const SizedBox(height: 18),
                _linkRow(),
                const SizedBox(height: 16),
                _allowRow(),
                const SizedBox(height: 18),
                _channelRow(),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _qrCard() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(20),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          SizedBox(
            width: 176,
            height: 176,
            child: CustomPaint(painter: const _QrPainter()),
          ),
          const SizedBox(height: 14),
          const Text(
            'Scan to open the board',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w600,
              color: _ink,
            ),
          ),
          const SizedBox(height: 2),
          const Text(
            'Point any camera here',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12,
              fontWeight: FontWeight.w500,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

  Widget _linkRow() {
    return Container(
      padding: const EdgeInsets.fromLTRB(16, 6, 6, 6),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.link_rounded, size: 18, color: _muted),
          const SizedBox(width: 10),
          const Expanded(
            child: Text(
              _link,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w600,
                color: _ink,
              ),
            ),
          ),
          const SizedBox(width: 8),
          SizedBox(
            height: 40,
            child: FilledButton(
              onPressed: () {
                setState(() => _copied = true);
                widget.onCopyLink?.call();
              },
              style: FilledButton.styleFrom(
                backgroundColor: _copied ? _ink : _brand,
                foregroundColor: _canvas,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(10),
                ),
              ),
              child: Text(
                _copied ? 'Copied' : 'Copy',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 13.5,
                  fontWeight: FontWeight.w700,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _allowRow() {
    return Row(
      children: <Widget>[
        Container(
          width: 38,
          height: 38,
          decoration: BoxDecoration(
            color: _surface,
            borderRadius: BorderRadius.circular(11),
          ),
          child: const Icon(Icons.group_add_rounded, size: 19, color: _ink),
        ),
        const SizedBox(width: 12),
        const Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                'Allow others to add items',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 14,
                  fontWeight: FontWeight.w700,
                  color: _ink,
                ),
              ),
              SizedBox(height: 1),
              Text(
                'Collaborators can save into this board',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12,
                  fontWeight: FontWeight.w500,
                  color: _muted,
                ),
              ),
            ],
          ),
        ),
        Switch(
          value: _allowAdds,
          onChanged: (bool v) => setState(() => _allowAdds = v),
          activeThumbColor: _canvas,
          activeTrackColor: _brand,
          inactiveThumbColor: _canvas,
          inactiveTrackColor: _faint,
          trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
        ),
      ],
    );
  }

  Widget _channelRow() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: <Widget>[
        for (final _Channel c in _channels)
          Expanded(
            child: GestureDetector(
              onTap: () => widget.onChannel?.call(c.label),
              behavior: HitTestBehavior.opaque,
              child: Column(
                children: <Widget>[
                  Container(
                    width: 54,
                    height: 54,
                    decoration: BoxDecoration(
                      color: c.tint.withValues(alpha: 0.10),
                      borderRadius: BorderRadius.circular(16),
                    ),
                    child: Icon(c.icon, size: 23, color: c.tint),
                  ),
                  const SizedBox(height: 7),
                  Text(
                    c.label,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                      color: _ink,
                    ),
                  ),
                ],
              ),
            ),
          ),
      ],
    );
  }
}

class _Channel {
  const _Channel(this.label, this.icon, this.tint);
  final String label;
  final IconData icon;
  final Color tint;
}

/// Paints a stylised QR code: three finder squares plus a deterministic module
/// grid driven by a fixed bit table (no randomness, no network) so it renders
/// identically every frame and in the golden harness.
class _QrPainter extends CustomPainter {
  const _QrPainter();

  // 21×21 module field. Finder patterns are drawn separately; the data area is
  // filled from this hand-authored bit table so the result looks like a real
  // QR without encoding anything scannable.
  static const int _n = 21;

  @override
  void paint(Canvas canvas, Size size) {
    final double m = size.width / _n; // module size
    final Paint dark = Paint()..color = const Color(0xFF222222);

    // Quiet-zone background.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(0, 0, size.width, size.height),
        const Radius.circular(8),
      ),
      Paint()..color = const Color(0xFFFFFFFF),
    );

    // Data modules: a fixed pseudo-pattern (avoid the finder corners).
    for (int y = 0; y < _n; y++) {
      for (int x = 0; x < _n; x++) {
        if (_inFinder(x, y)) continue;
        // Deterministic checker-ish hash — stable across frames.
        final int h = (x * 7 + y * 13 + x * y * 3) % 5;
        if (h == 0 || h == 2) {
          canvas.drawRect(
            Rect.fromLTWH(x * m + m * 0.08, y * m + m * 0.08, m * 0.84, m * 0.84),
            dark,
          );
        }
      }
    }

    // Three finder patterns (top-left, top-right, bottom-left).
    _finder(canvas, 0, 0, m, dark);
    _finder(canvas, (_n - 7) * m, 0, m, dark);
    _finder(canvas, 0, (_n - 7) * m, m, dark);

    // Brand centre dot — the StyleCart heart accent inside the QR.
    final double cx = size.width / 2;
    final double cy = size.height / 2;
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromCenter(center: Offset(cx, cy), width: m * 4.4, height: m * 4.4),
        Radius.circular(m),
      ),
      Paint()..color = const Color(0xFFFFFFFF),
    );
    canvas.drawCircle(Offset(cx, cy), m * 1.4, Paint()..color = _brandRed);
  }

  static const Color _brandRed = Color(0xFFFF385C);

  bool _inFinder(int x, int y) {
    const int f = 8; // 7-module finder + 1 separator
    final bool tl = x < f && y < f;
    final bool tr = x >= _n - f && y < f;
    final bool bl = x < f && y >= _n - f;
    return tl || tr || bl;
  }

  void _finder(Canvas canvas, double left, double top, double m, Paint dark) {
    // Outer 7×7 ring.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(left, top, m * 7, m * 7),
        Radius.circular(m * 1.6),
      ),
      dark,
    );
    // Punch the white gap.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(left + m, top + m, m * 5, m * 5),
        Radius.circular(m * 1.1),
      ),
      Paint()..color = const Color(0xFFFFFFFF),
    );
    // Solid inner 3×3.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(left + m * 2, top + m * 2, m * 3, m * 3),
        Radius.circular(m * 0.8),
      ),
      dark,
    );
  }

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

Plus bundled 5 binary assets (fonts/images). The CLI and MCP install those for you automatically.

Two faster ways to add it

Copy-paste works, but you can skip it entirely.

1. FlutterKit CLI

One command drops this screen — and its fonts — straight into your project.

$ flutterkit add ecom-wishlist-share

2. AI agent (MCP)

Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install ecom-wishlist-share — it fetches and writes the files for you.

FAQ

Can I use this share sheet in a commercial app?

Yes. FlutterKit screens are free for personal and commercial projects under an MIT-style licence. There is no key to enter — run `flutterkit add ecom-wishlist-share`, drop the file into your project and ship it, with or without attribution.

Does it need any pub packages or a QR library?

No. The file is pure Flutter with no pub dependencies — the QR is drawn by `_QrPainter` using nothing but `Canvas` calls. The only asset is the Manrope font, which `flutterkit add` bundles and registers in your pubspec for you.

Which Flutter version does this require?

Flutter 3.22 or newer. The code uses `super.key` in the constructor, `Color.withValues(alpha: 0.10)` on the channel tiles and `WidgetStateProperty` on the switch. On an older 3.x SDK swap `withValues(alpha: x)` for `withOpacity(x)`, `WidgetStateProperty` for `MaterialStateProperty`, and `activeThumbColor` for `activeColor`.

The QR is decorative — how do I make it actually scannable?

`_QrPainter` fills modules from the hash `(x * 7 + y * 13 + x * y * 3) % 5`, so it looks right but encodes nothing. To make it real, add the `qr_flutter` package and replace the `CustomPaint(painter: const _QrPainter())` inside `_qrCard` with `QrImageView(data: _link, size: 176)`. Keep the white bordered card around it for contrast, and drop the centre dot unless you use a high error-correction level, since covering modules breaks decoding.

Why does the Copy button never go back to 'Copy'?

`_copied` is set to true in `onPressed` and never reset, because the sheet is dismissed shortly after and a permanent 'Copied' state is clearer than a flash. If you want it to revert, start a `Future.delayed(const Duration(seconds: 2))` after the `setState` call and set `_copied = false` inside it, guarding with `if (mounted)`.

Related screens