E-commerce59 views

How to Build an Empty Wishlist Screen in Flutter (Full Code + Preview)

A wishlist tab with nothing in it is the moment a shopper decides whether saving items is worth the effort. This tutorial builds StyleCart's empty wishlist screen in Flutter: a 150px painted heart illustration with a pink spark, a 'No saves yet' headline, a line that teaches the heart-tap gesture in one sentence, a full-width Browse products button, and a horizontal Trending now rail whose cards each carry a heart badge — four real products to make the first save one tap away.

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

What you'll build

  • A CustomPainter heart — white fill, 2.6px dark outline, pink spark glint — that reads as unfilled rather than broken
  • Subline copy that names the heart control instead of apologising for the empty tab
  • A 54px 'Browse products' FilledButton in StyleCart's #FF385C brand pink
  • A 226px horizontal 'Trending now' rail of four tappable product cards, each wearing an outlined heart badge
  • A scrollable zero-state that survives a small phone without a single overflow stripe

Step-by-step build

1

Create the file

Add a new file at lib/ecom_wishlist_empty/ecom_wishlist_empty_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 trending products as data

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

/// StyleCart — Empty Wishlist.
///
/// The zero-state for the Wishlist tab: a painted broken-heart illustration, a
/// nudge to start saving, a "Browse products" CTA and a trending rail to seed
/// the first save.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. The
/// illustration is a CustomPainter (no emoji glyph). Exposes callbacks only.
class EcomWishlistEmptyScreen extends StatelessWidget {
  const EcomWishlistEmptyScreen({
    super.key,
    this.onBack,
    this.onBrowse,
    this.onProduct,
  });

  final VoidCallback? onBack;
  final VoidCallback? onBrowse;
  final ValueChanged<String>? onProduct;

  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 _brand = Color(0xFFFF385C);
  static const Color _imageBg = Color(0xFFF5F5F5);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const String _dir = 'lib/screens/ecommerce/ecom_wishlist_empty/images';

  static const List<_P> _trending = <_P>[
    _P('Oversized blazer', 'Atelier', 176, 'p07.webp'),
    _P('Ribbed knit tank', 'Aria', 48, 'p08.webp'),
    _P('Tapered chino', 'Stride', 88, 'p09.webp'),
    _P('Suede loafers', 'Stride', 124, 'p10.webp'),
  ];

The screen is a `StatelessWidget` with three optional callbacks — `onBack`, `onBrowse`, `onProduct` — because a wishlist holding zero items has no state of its own to mutate; every tap leaves for somewhere else. `_brand` (#FF385C) is spent in exactly two places, the Browse button and the painter's spark, so the pink always means 'act here'. `_imageBg` (#F5F5F5) does double duty as the rail's thumbnail backing and the illustration's disc. The four trending items live in a `static const List<_P>`, each a title, brand, price and webp filename, with `_dir` holding the shared asset folder so a card only names its own file.

A scrolling zero-state, not a centred one

ecom_wishlist_empty_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.only(bottom: 28),
                  children: <Widget>[
                    const SizedBox(height: 40),
                    Center(
                      child: SizedBox(
                        width: 150,
                        height: 150,
                        child: CustomPaint(painter: _HeartArtPainter()),
                      ),
                    ),

`Theme(data: ThemeData.light(useMaterial3: true))` pins the light look regardless of the host app, then a white `Scaffold` and `SafeArea` wrap a Column of header, a 1px `Divider` in `_hairline`, and an `Expanded` `ListView`. Scrolling matters here: the illustration, headline, subline, CTA and a 226px rail together exceed a small phone, and a `Column` with `Spacer`s would overflow the moment the rail appeared. The list's `EdgeInsets.only(bottom: 28)` keeps the last row clear of the home indicator. A 40px gap, then a `Center` holding a fixed 150×150 `SizedBox` — the painter works in fractions of `size`, so the box is what sets the artwork's scale.

Copy that names the heart, and the browse CTA

ecom_wishlist_empty_screen.dart
                    const SizedBox(height: 28),
                    const Text(
                      'No saves yet',
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 21,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.3,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 8),
                    const Padding(
                      padding: EdgeInsets.symmetric(horizontal: 48),
                      child: Text(
                        'Tap the heart on anything you love and it’ll '
                        'land here — ready when you are.',
                        textAlign: TextAlign.center,
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 14.5,
                          fontWeight: FontWeight.w500,
                          height: 1.5,
                          color: _muted,
                        ),
                      ),
                    ),
                    const SizedBox(height: 24),
                    Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 40),
                      child: SizedBox(
                        height: 54,
                        child: FilledButton(
                          onPressed: onBrowse,
                          style: FilledButton.styleFrom(
                            backgroundColor: _brand,
                            foregroundColor: _canvas,
                            shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.circular(16),
                            ),
                          ),
                          child: const Text(
                            'Browse products',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 16,
                              fontWeight: FontWeight.w700,
                            ),
                          ),
                        ),
                      ),
                    ),

'No saves yet' lands at 21px `w800` with `letterSpacing: -0.3`, and 8px under it the subline reads 'Tap the heart on anything you love and it'll land here — ready when you are.' That sentence is the whole job of the screen: a wishlist is empty because the shopper hasn't noticed the heart, so the copy points at the control rather than apologising. `EdgeInsets.symmetric(horizontal: 48)` forces a comfortable two-line wrap at 14.5px with `height: 1.5` in `_muted`. After 24px, the `FilledButton` sits in 40px side padding at a fixed `height: 54` with a 16px radius and brand fill, firing `onBrowse`.

Handing off to the trending rail

ecom_wishlist_empty_screen.dart
                    const SizedBox(height: 44),
                    const Padding(
                      padding: EdgeInsets.fromLTRB(20, 0, 20, 14),
                      child: Text(
                        'Trending now',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 17,
                          fontWeight: FontWeight.w700,
                          letterSpacing: -0.3,
                          color: _ink,
                        ),
                      ),
                    ),
                    _rail(),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

A deliberate 44px gap — the largest on the screen — separates the zero-state block from what follows, so the rail reads as a second offer rather than part of the illustration. 'Trending now' is set at 17px `w700` with `EdgeInsets.fromLTRB(20, 0, 20, 14)`, its 20px left inset matching the rail's own padding so title and first card share an edge. Both the heading and `_rail()` are children of the same `ListView`, which means the section scrolls away with the artwork instead of pinning; the rail's own horizontal scrolling is nested inside that vertical list without conflict, since the axes differ.

A header that keeps the tab's identity

ecom_wishlist_empty_screen.dart
  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 4),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 20, color: _ink),
          ),
          const Expanded(
            child: Text(
              'Wishlist',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
        ],
      ),
    );
  }

`EdgeInsets.fromLTRB(8, 4, 20, 4)` uses only 8px on the left because the `IconButton` already carries a 48px touch target that would otherwise push the arrow too far inboard. The glyph is `Icons.arrow_back_ios_new_rounded` at 20px in `_ink`. The 'Wishlist' title sits inside an `Expanded` rather than being centred, so it reads as a left-aligned screen name beside the arrow and the row's width is claimed in one widget. There is no trailing action — a wishlist with zero saves has nothing to edit, share or clear, so an overflow menu would open onto disabled items.

The Trending now rail

ecom_wishlist_empty_screen.dart
  Widget _rail() {
    return SizedBox(
      height: 226,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20),
        itemCount: _trending.length,
        separatorBuilder: (_, _) => const SizedBox(width: 14),
        itemBuilder: (BuildContext context, int i) {
          final _P p = _trending[i];
          return GestureDetector(
            onTap: () => onProduct?.call(p.title),
            child: SizedBox(
              width: 140,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Expanded(
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(14),
                      child: Stack(
                        fit: StackFit.expand,
                        children: <Widget>[
                          Container(color: _imageBg),
                          Image.asset('$_dir/${p.asset}', fit: BoxFit.cover),
                          const Positioned(top: 8, right: 8, child: _Heart()),
                        ],
                      ),
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    p.brand.toUpperCase(),
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 10,
                      fontWeight: FontWeight.w600,
                      letterSpacing: 0.6,
                      color: _muted,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    p.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w600,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 3),
                  Text(
                    '\$${p.price}',
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 14,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

A fixed `SizedBox(height: 226)` gives the horizontal `ListView.separated` a bound, which lets each card's `Column` hand the image an `Expanded` while the four text lines take their intrinsic heights. Separators are 14px; each card is 140 wide. The whole card is a `GestureDetector` calling `onProduct?.call(p.title)`. Inside a `ClipRRect` with a 14px radius, a `Stack` layers `Container(color: _imageBg)` beneath `Image.asset` so a decoding frame shows grey rather than white flash, with the `_Heart` badge pinned at `top: 8, right: 8`. Brand is uppercased at 10px with `letterSpacing: 0.6`, the title clipped to one line with `TextOverflow.ellipsis`, and the price bolded at `w700`.

The heart badge and the product record

ecom_wishlist_empty_screen.dart
class _Heart extends StatelessWidget {
  const _Heart();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 30,
      height: 30,
      decoration: const BoxDecoration(
        color: Color(0xFFFFFFFF),
        shape: BoxShape.circle,
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: Color(0x14000000),
            blurRadius: 5,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: const Icon(Icons.favorite_border_rounded,
          size: 16, color: Color(0xFF222222)),
    );
  }
}

class _P {
  const _P(this.title, this.brand, this.price, this.asset);
  final String title;
  final String brand;
  final int price;
  final String asset;
}

`_Heart` is a 30px white circle carrying `Icons.favorite_border_rounded` at 16px, lifted off the photo by a single `BoxShadow` at `Color(0x14000000)`, `blurRadius: 5`, `offset: Offset(0, 2)` — enough separation to stay legible over a busy garment shot without a hard ring. The glyph is the *outline* variant on every card, which is the point: nothing here is saved yet, so the badge shows the shopper the exact control the subline just described. Note it has no `onTap` of its own — the enclosing `GestureDetector` sends taps to `onProduct` instead. `_P` is a four-field const record holding title, brand, price and asset name.

Painting the heart and its spark

ecom_wishlist_empty_screen.dart
/// Paints a soft outlined heart with a brand spark, the wishlist zero-state
/// motif — empty but inviting.
class _HeartArtPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;

    // Rounded backdrop.
    canvas.drawCircle(
      Offset(w / 2, h / 2),
      w / 2,
      Paint()..color = const Color(0xFFF5F5F5),
    );

    // Heart outline, centred.
    final Path heart = _heartPath(
      Offset(w / 2, h * 0.52),
      w * 0.42,
      h * 0.40,
    );
    canvas.drawPath(heart, Paint()..color = const Color(0xFFFFFFFF));
    canvas.drawPath(
      heart,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2.6
        ..strokeJoin = StrokeJoin.round
        ..color = const Color(0xFF222222),
    );

    // Brand spark — a tiny plus glint to the upper-right.
    final Paint spark = Paint()
      ..color = const Color(0xFFFF385C)
      ..strokeWidth = 2.4
      ..strokeCap = StrokeCap.round;
    final Offset s = Offset(w * 0.74, h * 0.30);
    canvas.drawLine(s.translate(-5, 0), s.translate(5, 0), spark);
    canvas.drawLine(s.translate(0, -5), s.translate(0, 5), spark);
  }

  Path _heartPath(Offset c, double width, double height) {
    final double l = c.dx - width / 2;
    final double t = c.dy - height / 2;
    final Path p = Path();
    p.moveTo(c.dx, t + height);
    p.cubicTo(
      l - width * 0.05, t + height * 0.62,
      l + width * 0.10, t - height * 0.10,
      c.dx, t + height * 0.30,
    );
    p.cubicTo(
      l + width * 0.90, t - height * 0.10,
      l + width * 1.05, t + height * 0.62,
      c.dx, t + height,
    );
    p.close();
    return p;
  }

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

Despite the 'broken heart' label in the docs, `_HeartArtPainter` draws a whole one — unfilled, not broken, a friendlier read of zero saves. A #F5F5F5 disc at radius `w / 2` fills the box, then the heart sits at `(w / 2, h * 0.52)` — below true centre so the lobes clear the disc edge — sized `w * 0.42` by `h * 0.40`, filled white then stroked at 2.6px in #222. The pink spark is two crossed 10px `drawLine` calls at `(w * 0.74, h * 0.30)`. `_heartPath` starts at the bottom point and runs two mirrored cubics whose control points overshoot the box to bulge each lobe, meeting at the cleft at `t + height * 0.30`.

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 — Empty Wishlist.
///
/// The zero-state for the Wishlist tab: a painted broken-heart illustration, a
/// nudge to start saving, a "Browse products" CTA and a trending rail to seed
/// the first save.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. The
/// illustration is a CustomPainter (no emoji glyph). Exposes callbacks only.
class EcomWishlistEmptyScreen extends StatelessWidget {
  const EcomWishlistEmptyScreen({
    super.key,
    this.onBack,
    this.onBrowse,
    this.onProduct,
  });

  final VoidCallback? onBack;
  final VoidCallback? onBrowse;
  final ValueChanged<String>? onProduct;

  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 _brand = Color(0xFFFF385C);
  static const Color _imageBg = Color(0xFFF5F5F5);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const String _dir = 'lib/screens/ecommerce/ecom_wishlist_empty/images';

  static const List<_P> _trending = <_P>[
    _P('Oversized blazer', 'Atelier', 176, 'p07.webp'),
    _P('Ribbed knit tank', 'Aria', 48, 'p08.webp'),
    _P('Tapered chino', 'Stride', 88, 'p09.webp'),
    _P('Suede loafers', 'Stride', 124, 'p10.webp'),
  ];

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.only(bottom: 28),
                  children: <Widget>[
                    const SizedBox(height: 40),
                    Center(
                      child: SizedBox(
                        width: 150,
                        height: 150,
                        child: CustomPaint(painter: _HeartArtPainter()),
                      ),
                    ),
                    const SizedBox(height: 28),
                    const Text(
                      'No saves yet',
                      textAlign: TextAlign.center,
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 21,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.3,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 8),
                    const Padding(
                      padding: EdgeInsets.symmetric(horizontal: 48),
                      child: Text(
                        'Tap the heart on anything you love and it’ll '
                        'land here — ready when you are.',
                        textAlign: TextAlign.center,
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 14.5,
                          fontWeight: FontWeight.w500,
                          height: 1.5,
                          color: _muted,
                        ),
                      ),
                    ),
                    const SizedBox(height: 24),
                    Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 40),
                      child: SizedBox(
                        height: 54,
                        child: FilledButton(
                          onPressed: onBrowse,
                          style: FilledButton.styleFrom(
                            backgroundColor: _brand,
                            foregroundColor: _canvas,
                            shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.circular(16),
                            ),
                          ),
                          child: const Text(
                            'Browse products',
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 16,
                              fontWeight: FontWeight.w700,
                            ),
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(height: 44),
                    const Padding(
                      padding: EdgeInsets.fromLTRB(20, 0, 20, 14),
                      child: Text(
                        'Trending now',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 17,
                          fontWeight: FontWeight.w700,
                          letterSpacing: -0.3,
                          color: _ink,
                        ),
                      ),
                    ),
                    _rail(),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 4),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: onBack,
            icon: const Icon(Icons.arrow_back_ios_new_rounded,
                size: 20, color: _ink),
          ),
          const Expanded(
            child: Text(
              'Wishlist',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _rail() {
    return SizedBox(
      height: 226,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20),
        itemCount: _trending.length,
        separatorBuilder: (_, _) => const SizedBox(width: 14),
        itemBuilder: (BuildContext context, int i) {
          final _P p = _trending[i];
          return GestureDetector(
            onTap: () => onProduct?.call(p.title),
            child: SizedBox(
              width: 140,
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Expanded(
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(14),
                      child: Stack(
                        fit: StackFit.expand,
                        children: <Widget>[
                          Container(color: _imageBg),
                          Image.asset('$_dir/${p.asset}', fit: BoxFit.cover),
                          const Positioned(top: 8, right: 8, child: _Heart()),
                        ],
                      ),
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    p.brand.toUpperCase(),
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 10,
                      fontWeight: FontWeight.w600,
                      letterSpacing: 0.6,
                      color: _muted,
                    ),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    p.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w600,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 3),
                  Text(
                    '\$${p.price}',
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 14,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

class _Heart extends StatelessWidget {
  const _Heart();

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 30,
      height: 30,
      decoration: const BoxDecoration(
        color: Color(0xFFFFFFFF),
        shape: BoxShape.circle,
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: Color(0x14000000),
            blurRadius: 5,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: const Icon(Icons.favorite_border_rounded,
          size: 16, color: Color(0xFF222222)),
    );
  }
}

class _P {
  const _P(this.title, this.brand, this.price, this.asset);
  final String title;
  final String brand;
  final int price;
  final String asset;
}

/// Paints a soft outlined heart with a brand spark, the wishlist zero-state
/// motif — empty but inviting.
class _HeartArtPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;

    // Rounded backdrop.
    canvas.drawCircle(
      Offset(w / 2, h / 2),
      w / 2,
      Paint()..color = const Color(0xFFF5F5F5),
    );

    // Heart outline, centred.
    final Path heart = _heartPath(
      Offset(w / 2, h * 0.52),
      w * 0.42,
      h * 0.40,
    );
    canvas.drawPath(heart, Paint()..color = const Color(0xFFFFFFFF));
    canvas.drawPath(
      heart,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2.6
        ..strokeJoin = StrokeJoin.round
        ..color = const Color(0xFF222222),
    );

    // Brand spark — a tiny plus glint to the upper-right.
    final Paint spark = Paint()
      ..color = const Color(0xFFFF385C)
      ..strokeWidth = 2.4
      ..strokeCap = StrokeCap.round;
    final Offset s = Offset(w * 0.74, h * 0.30);
    canvas.drawLine(s.translate(-5, 0), s.translate(5, 0), spark);
    canvas.drawLine(s.translate(0, -5), s.translate(0, 5), spark);
  }

  Path _heartPath(Offset c, double width, double height) {
    final double l = c.dx - width / 2;
    final double t = c.dy - height / 2;
    final Path p = Path();
    p.moveTo(c.dx, t + height);
    p.cubicTo(
      l - width * 0.05, t + height * 0.62,
      l + width * 0.10, t - height * 0.10,
      c.dx, t + height * 0.30,
    );
    p.cubicTo(
      l + width * 0.90, t - height * 0.10,
      l + width * 1.05, t + height * 0.62,
      c.dx, t + height,
    );
    p.close();
    return p;
  }

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

Plus bundled 9 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-empty

2. AI agent (MCP)

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

FAQ

Is this empty wishlist screen free to use commercially?

Yes — free, with nothing paid behind it. Copy the Dart from this page, install it with the CLI, or pull it through MCP, and ship it in a real shopping app. No sign-up, no licence key, no attribution line.

Which packages, fonts and images does it need?

No pub packages at all — `material.dart` only. Text uses bundled Manrope via `fontFamily: 'Manrope'`, and the four rail cards load webp files from `lib/screens/ecommerce/ecom_wishlist_empty/images`. Point `_dir` at your own asset folder, or replace `Image.asset` with `Image.network` for a live catalogue; the grey `_imageBg` layer beneath already covers the loading frame.

How do I make the heart badge actually save a product?

`_Heart` is decorative today — it has no `onTap`, and the card's `GestureDetector` routes every tap to `onProduct`. Convert the screen to a `StatefulWidget`, hold a `Set<String>` of saved titles, wrap `_Heart` in its own `GestureDetector`, and pass a `saved` flag so it swaps `Icons.favorite_border_rounded` for `Icons.favorite_rounded` in `_brand`.

Why put a trending rail on a screen about having nothing saved?

Because the only fix for an empty wishlist is a first save, and the Browse button costs a navigation before the shopper sees a single product. Four real cards with prices and heart badges put a candidate item on this screen, so the tab can go from zero to one without leaving it.

Which Flutter version does this need?

Flutter 3.29 or newer, because `separatorBuilder: (_, _) =>` uses Dart 3.7 wildcard parameters. On an older SDK rename them to `(_, __)`, and if you are below 3.22 also expand the constructor from `super.key` to the `{Key? key, ...}) : super(key: key)` form.

Related screens