E-commerce37 views

How to Build a Wishlist Grid and List Screen in Flutter (Full Code + Preview)

A wishlist is where shoppers park intent, and the screen earns its place by showing what changed since they saved. This tutorial builds StyleCart's Wishlist tab in Flutter: a single `_grid` bool flips six saved products between a two-column `GridView.builder` and a `ListView.separated`, a `_DropFlagPainter` draws a notched red ribbon reading the money saved on any item whose `was` price is set, and every card carries a floating heart plus an outlined Move to bag button. Pure Flutter, one file, callbacks only.

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

What you'll build

  • A header segmented toggle where `_grid` swaps a `GridView.builder` for a `ListView.separated` under the same `Expanded`
  • A `_Fav` model whose nullable `was` field drives both the red price colour and the painted price-drop flag
  • A `_DropFlagPainter` that paths a notched tag shape and punches a translucent hole so it reads as a hanging price tag
  • A `_priceRow` that struck-through the original price in `_faint` only when a drop exists
  • A live saved count in the header that reads `_items.length` so it never drifts from the list

Step-by-step build

1

Create the file

Add a new file at lib/ecom_wishlist_main/ecom_wishlist_main_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, Airbnb-style tokens and the saved-items model

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

/// StyleCart — Wishlist (the Wishlist tab).
///
/// The shopper's saved products as a two-column grid (or a comfortable list),
/// each with a filled wishlist heart to un-save, a painted price-drop flag for
/// items that fell in price, and a move-to-bag action. A header toggle flips
/// between grid and list density.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. The price-drop
/// flag is a CustomPainter (no emoji glyph). Exposes callbacks only.
class EcomWishlistMainScreen extends StatefulWidget {
  const EcomWishlistMainScreen({
    super.key,
    this.onBack,
    this.onProduct,
    this.onMoveToBag,
    this.onCollections,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onProduct;
  final ValueChanged<String>? onMoveToBag;
  final VoidCallback? onCollections;

  @override
  State<EcomWishlistMainScreen> createState() => _EcomWishlistMainScreenState();
}

class _EcomWishlistMainScreenState extends State<EcomWishlistMainScreen> {
  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 _imageBg = Color(0xFFF5F5F5);
  static const Color _hairline = Color(0xFFEBEBEB);

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

  final List<_Fav> _items = <_Fav>[
    const _Fav('Washed cotton overshirt', 'Atelier', 118, null, 'Sand · M',
        'p01.webp'),
    const _Fav('Pleated midi skirt', 'Aria', 84, 112, 'Olive · S', 'p02.webp'),
    const _Fav('Court leather sneakers', 'Stride', 95, null, 'White · 42',
        'p03.webp'),
    const _Fav('Belted wool coat', 'Atelier', 168, 198, 'Camel · M',
        'p04.webp'),
    const _Fav('Silk slip dress', 'Aria', 138, null, 'Blush · S', 'p05.webp'),
    const _Fav('Boxy denim jacket', 'Stride', 109, 134, 'Indigo · L',
        'p06.webp'),
  ];

  bool _grid = true;

`EcomWishlistMainScreen` is a `StatefulWidget` because one piece of UI state lives here: `bool _grid = true`, the density toggle. It exposes four callbacks and nothing else — `onBack`, `onProduct` and `onMoveToBag` (both `ValueChanged<String>` carrying the product title), and `onCollections` — so persistence stays in the host app. The palette is deliberately Airbnb-flavoured: `_ink` `0xFF222222` for text, `_brand` `0xFFFF385C` coral for hearts and drops, `_faint` `0xFFC1C1C1` for struck prices and inactive icons, and `_imageBg` `0xFFF5F5F5` sitting behind every image so a slow-loading webp never flashes white. The six `_Fav` entries hold `price` and a nullable `was`; three of them (`112`, `198`, `134`) set `was`, and that single null check is what later decides whether a card shows the drop flag and red price. `_dir` points at the bundled webp folder.

Build tree and a header with a live count

ecom_wishlist_main_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),
              _collectionsRow(),
              Expanded(
                child: _grid ? _gridView() : _listView(),
              ),
            ],
          ),
        ),
      ),
    );
  }

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

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the screen ignores whatever dark theme the host app runs, then a `Scaffold` on `_canvas` white and a `SafeArea` `Column`: header, a one-pixel `Divider` in `_hairline` `0xFFEBEBEB`, the Collections row, and an `Expanded` whose child is the ternary `_grid ? _gridView() : _listView()`. That ternary is the whole toggle mechanism — no `PageView`, no animation, just a rebuild swapping one scrolling widget for another. `_header` uses asymmetric padding `fromLTRB(8, 4, 12, 4)` because the `IconButton` on the left has its own 48px hit area. The title row prints `'Wishlist'` at 19px w800 with `-0.3` letter spacing, then `'${_items.length}'` in `_muted` 14px — a count derived from the list, so removing an item can never leave a stale number. `_toggle()` sits at the trailing end.

The segmented grid/list toggle

ecom_wishlist_main_screen.dart

  Widget _toggle() {
    return Container(
      margin: const EdgeInsets.only(right: 4),
      padding: const EdgeInsets.all(3),
      decoration: BoxDecoration(
        color: _imageBg,
        borderRadius: BorderRadius.circular(10),
      ),
      child: Row(
        children: <Widget>[
          _toggleBtn(Icons.grid_view_rounded, _grid, () {
            setState(() => _grid = true);
          }),
          _toggleBtn(Icons.view_agenda_outlined, !_grid, () {
            setState(() => _grid = false);
          }),
        ],
      ),
    );
  }

  Widget _toggleBtn(IconData icon, bool active, VoidCallback onTap) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        padding: const EdgeInsets.all(6),
        decoration: BoxDecoration(
          color: active ? _canvas : Colors.transparent,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Icon(icon, size: 19, color: active ? _ink : _faint),
      ),
    );
  }

The toggle is a hand-built segmented control rather than a `SegmentedButton`: an outer `Container` in `_imageBg` with radius 10 and 3px padding, holding two `_toggleBtn` calls. Each button is a `GestureDetector` around a 6px-padded `Container` whose `color` is `_canvas` white when `active` and transparent otherwise, with radius 8 — two pixels tighter than the outer 10 so the inner pill sits concentrically inside the 3px gutter. The icon flips colour with the same bool, `_ink` for active and `_faint` for idle, so the selected state is expressed twice (background plus icon) and reads even in a screenshot. The two callbacks simply `setState(() => _grid = true)` and `false`; passing `_grid` and `!_grid` as the `active` argument means both pills can never be lit at once. Icons are `grid_view_rounded` and `view_agenda_outlined`.

The tappable Collections row

ecom_wishlist_main_screen.dart
  Widget _collectionsRow() {
    return GestureDetector(
      onTap: widget.onCollections,
      behavior: HitTestBehavior.opaque,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 14, 16, 14),
        child: Row(
          children: <Widget>[
            Container(
              width: 36,
              height: 36,
              decoration: BoxDecoration(
                color: _brand.withValues(alpha: 0.10),
                borderRadius: BorderRadius.circular(10),
              ),
              child: const Icon(Icons.folder_copy_rounded,
                  size: 19, color: _brand),
            ),
            const SizedBox(width: 12),
            const Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    'Collections',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 14.5,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                  SizedBox(height: 1),
                  Text(
                    '4 boards · organise your saves',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            const Icon(Icons.chevron_right_rounded, size: 22, color: _faint),
          ],
        ),
      ),
    );
  }

Between the header and the product list sits a single-row shortcut to saved boards. The `GestureDetector` sets `behavior: HitTestBehavior.opaque` — without it, taps on the empty padding between the icon and the chevron would fall through, because a bare `Row` with transparent gaps doesn't register hits. The leading tile is a 36×36 `Container` tinted with `_brand.withValues(alpha: 0.10)` behind a solid `_brand` `folder_copy_rounded` icon, the same two-layer treatment the store uses for accents elsewhere. Text is a `const Expanded(Column(...))` with `'Collections'` at 14.5px w700 and the subtitle `'4 boards · organise your saves'` at 12.5px `_muted`; the `SizedBox(height: 1)` between them is intentionally tiny so the pair reads as one label. A `_faint` `chevron_right_rounded` at 22px signals navigation, and the whole thing fires `widget.onCollections`.

Two-column grid and the tall product card

ecom_wishlist_main_screen.dart

  Widget _gridView() {
    return GridView.builder(
      padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 14,
        mainAxisSpacing: 18,
        childAspectRatio: 0.49,
      ),
      itemCount: _items.length,
      itemBuilder: (BuildContext context, int i) => _gridCard(_items[i]),
    );
  }

  Widget _gridCard(_Fav f) {
    return GestureDetector(
      onTap: () => widget.onProduct?.call(f.title),
      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/${f.asset}', fit: BoxFit.cover),
                  Positioned(top: 8, right: 8, child: _heart()),
                  if (f.was != null)
                    Positioned(
                      top: 10,
                      left: 0,
                      child: _dropFlag(f.was! - f.price),
                    ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 8),
          Text(
            f.brand.toUpperCase(),
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 10,
              fontWeight: FontWeight.w600,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
          const SizedBox(height: 2),
          Text(
            f.title,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w600,
              color: _ink,
            ),
          ),
          const SizedBox(height: 4),
          _priceRow(f),
          const SizedBox(height: 8),
          _moveBtn(f, full: true),
        ],
      ),
    );
  }

`_gridView` is a `GridView.builder` with `SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, crossAxisSpacing: 14, mainAxisSpacing: 18, childAspectRatio: 0.49)`. That 0.49 ratio makes each cell roughly twice as tall as it is wide, which is what leaves room under the image for brand, title, price and a 38px button. `_gridCard` puts the image in an `Expanded` so it absorbs whatever height the ratio grants, then clips it with `ClipRRect` radius 14. Inside, a `Stack` with `StackFit.expand` layers `_imageBg`, the `Image.asset` at `BoxFit.cover`, a `_heart()` positioned `top: 8, right: 8`, and — only `if (f.was != null)` — a `_dropFlag(f.was! - f.price)` at `top: 10, left: 0`, flush against the left edge so the painted tag looks stuck onto the photo. Below: the brand uppercased at 10px with 0.6 letter spacing, a one-line ellipsised title, `_priceRow`, then `_moveBtn(f, full: true)` stretching the button to the column width.

The comfortable list and its row layout

ecom_wishlist_main_screen.dart

  Widget _listView() {
    return ListView.separated(
      padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
      itemCount: _items.length,
      separatorBuilder: (_, _) =>
          const Divider(height: 28, color: _hairline),
      itemBuilder: (BuildContext context, int i) => _listRow(_items[i]),
    );
  }

  Widget _listRow(_Fav f) {
    return GestureDetector(
      onTap: () => widget.onProduct?.call(f.title),
      behavior: HitTestBehavior.opaque,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          ClipRRect(
            borderRadius: BorderRadius.circular(12),
            child: Stack(
              children: <Widget>[
                Container(
                  width: 92,
                  height: 116,
                  color: _imageBg,
                  child: Image.asset('$_dir/${f.asset}', fit: BoxFit.cover),
                ),
                if (f.was != null)
                  Positioned(top: 8, left: 0, child: _dropFlag(f.was! - f.price)),
              ],
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const SizedBox(height: 2),
                Text(
                  f.brand.toUpperCase(),
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 10,
                    fontWeight: FontWeight.w600,
                    letterSpacing: 0.6,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 3),
                Text(
                  f.title,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 15,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 3),
                Text(
                  f.variant,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w500,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 6),
                _priceRow(f),
                const SizedBox(height: 10),
                Row(
                  children: <Widget>[
                    Expanded(child: _moveBtn(f, full: true)),
                    const SizedBox(width: 10),
                    _heart(),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

`_listView` swaps the grid for a `ListView.separated` with the same `fromLTRB(20, 4, 20, 24)` padding, so toggling density doesn't shift the content edge. The separator is a `Divider(height: 28)` in `_hairline` — 28 logical pixels of breathing room with a hairline in the middle. `_listRow` is a top-aligned `Row`: a fixed 92×116 `Container` (a 4:5 portrait, matching the grid images) holding the webp, clipped at radius 12 and stacked with the same conditional `_dropFlag`. The text column now has room for a third line, so it adds `f.variant` (e.g. `'Olive · S'`) in `_muted` 12.5px between the 15px w700 title and the price row — information the grid card omits for space. The bottom `Row` places `_moveBtn` in an `Expanded` with the `_heart()` beside it, because there's no image overlay to host the heart at this density. Both densities call `widget.onProduct?.call(f.title)` on tap, and the row uses `HitTestBehavior.opaque` for the same reason as the Collections row.

Price row, Move to bag button and the floating heart

ecom_wishlist_main_screen.dart

  Widget _priceRow(_Fav f) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.end,
      children: <Widget>[
        Text(
          '\$${f.price}',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 15,
            fontWeight: FontWeight.w800,
            color: f.was != null ? _brand : _ink,
          ),
        ),
        if (f.was != null) ...<Widget>[
          const SizedBox(width: 6),
          Padding(
            padding: const EdgeInsets.only(bottom: 1),
            child: Text(
              '\$${f.was}',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w500,
                decoration: TextDecoration.lineThrough,
                decorationColor: _faint,
                color: _faint,
              ),
            ),
          ),
        ],
      ],
    );
  }

  Widget _moveBtn(_Fav f, {bool full = false}) {
    return SizedBox(
      height: 38,
      width: full ? double.infinity : null,
      child: OutlinedButton(
        onPressed: () => widget.onMoveToBag?.call(f.title),
        style: OutlinedButton.styleFrom(
          foregroundColor: _ink,
          side: const BorderSide(color: _ink, width: 1.3),
          padding: const EdgeInsets.symmetric(horizontal: 12),
          shape:
              RoundedRectangleBorder(borderRadius: BorderRadius.circular(11)),
        ),
        child: const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Icon(Icons.shopping_bag_outlined, size: 16, color: _ink),
            SizedBox(width: 6),
            Text(
              'Move to bag',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w700,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _heart() {
    return Container(
      width: 32,
      height: 32,
      decoration: const BoxDecoration(
        color: _canvas,
        shape: BoxShape.circle,
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: Color(0x1A000000),
            blurRadius: 6,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: const Icon(Icons.favorite_rounded, size: 17, color: _brand),
    );
  }

`_priceRow` aligns its children to `CrossAxisAlignment.end` so the smaller struck price sits on the baseline of the big one. The current price is 15px w800 and its colour is `f.was != null ? _brand : _ink` — coral only when there is a drop, which makes discounted items pop in a grid of black prices. The original price is added with a spread `...<Widget>[]` inside the `if`, rendered at 12.5px with `TextDecoration.lineThrough` and a matching `decorationColor: _faint` (set explicitly so the strike isn't drawn in the theme text colour). `_moveBtn` is a 38px `OutlinedButton` with a 1.3px `_ink` border, radius 11, and a `shopping_bag_outlined` icon plus `'Move to bag'` at 12.5px w700; `width: full ? double.infinity : null` lets the caller stretch it. `_heart` is a 32px white circle with a `0x1A000000` shadow (blur 6, offset 0,2) holding a filled `favorite_rounded` in `_brand` — filled because the item is already saved.

The price-drop flag and its CustomPainter

ecom_wishlist_main_screen.dart

  Widget _dropFlag(int saved) {
    return SizedBox(
      width: 74,
      height: 24,
      child: CustomPaint(
        painter: _DropFlagPainter(),
        child: Center(
          child: Padding(
            padding: const EdgeInsets.only(right: 6),
            child: Text(
              '−\$$saved',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 11.5,
                fontWeight: FontWeight.w800,
                color: _canvas,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Fav {
  const _Fav(this.title, this.brand, this.price, this.was, this.variant,
      this.asset);
  final String title;
  final String brand;
  final int price;
  final int? was;
  final String variant;
  final String asset;
}

/// Paints a price-drop ribbon flag: a rounded-left tag with a notched right
/// tail, filled brand red, sitting flush against the image edge.
class _DropFlagPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;
    const double notch = 8;

    final Path tag = Path()
      ..moveTo(0, 0)
      ..lineTo(w - notch, 0)
      ..lineTo(w, h / 2)
      ..lineTo(w - notch, h)
      ..lineTo(0, h)
      ..close();

    canvas.drawPath(tag, Paint()..color = const Color(0xFFFF385C));

    // Punch-hole on the tail to read as a hanging price tag.
    canvas.drawCircle(
      Offset(w - notch - 5, h / 2),
      2.2,
      Paint()..color = const Color(0x33FFFFFF),
    );
  }

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

`_dropFlag(int saved)` is a 74×24 `SizedBox` around a `CustomPaint` whose `child` is the label `'−\$$saved'` — a real minus sign — in 11.5px w800 white, padded 6px on the right so it doesn't run into the notch. `_DropFlagPainter.paint` builds a `Path` in five moves: from the top-left corner to `w - notch`, out to the tip at `(w, h / 2)`, back to `w - notch` at the bottom, along the bottom edge and close. With `notch = 8` that gives a rectangle whose right edge is a pointed arrow tip, filled with `0xFFFF385C`. Then a 2.2-radius circle at `(w - notch - 5, h / 2)` in `0x33FFFFFF` — 20% white — is drawn just inside the tip, reading as the punch hole of a hanging price tag. `shouldRepaint` returns `false` because the painter has no fields; the amount lives in the child `Text`, not the painter. `_Fav` above it is a plain six-field const value type.

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 — Wishlist (the Wishlist tab).
///
/// The shopper's saved products as a two-column grid (or a comfortable list),
/// each with a filled wishlist heart to un-save, a painted price-drop flag for
/// items that fell in price, and a move-to-bag action. A header toggle flips
/// between grid and list density.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. The price-drop
/// flag is a CustomPainter (no emoji glyph). Exposes callbacks only.
class EcomWishlistMainScreen extends StatefulWidget {
  const EcomWishlistMainScreen({
    super.key,
    this.onBack,
    this.onProduct,
    this.onMoveToBag,
    this.onCollections,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onProduct;
  final ValueChanged<String>? onMoveToBag;
  final VoidCallback? onCollections;

  @override
  State<EcomWishlistMainScreen> createState() => _EcomWishlistMainScreenState();
}

class _EcomWishlistMainScreenState extends State<EcomWishlistMainScreen> {
  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 _imageBg = Color(0xFFF5F5F5);
  static const Color _hairline = Color(0xFFEBEBEB);

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

  final List<_Fav> _items = <_Fav>[
    const _Fav('Washed cotton overshirt', 'Atelier', 118, null, 'Sand · M',
        'p01.webp'),
    const _Fav('Pleated midi skirt', 'Aria', 84, 112, 'Olive · S', 'p02.webp'),
    const _Fav('Court leather sneakers', 'Stride', 95, null, 'White · 42',
        'p03.webp'),
    const _Fav('Belted wool coat', 'Atelier', 168, 198, 'Camel · M',
        'p04.webp'),
    const _Fav('Silk slip dress', 'Aria', 138, null, 'Blush · S', 'p05.webp'),
    const _Fav('Boxy denim jacket', 'Stride', 109, 134, 'Indigo · L',
        'p06.webp'),
  ];

  bool _grid = true;

  @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),
              _collectionsRow(),
              Expanded(
                child: _grid ? _gridView() : _listView(),
              ),
            ],
          ),
        ),
      ),
    );
  }

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

  Widget _toggle() {
    return Container(
      margin: const EdgeInsets.only(right: 4),
      padding: const EdgeInsets.all(3),
      decoration: BoxDecoration(
        color: _imageBg,
        borderRadius: BorderRadius.circular(10),
      ),
      child: Row(
        children: <Widget>[
          _toggleBtn(Icons.grid_view_rounded, _grid, () {
            setState(() => _grid = true);
          }),
          _toggleBtn(Icons.view_agenda_outlined, !_grid, () {
            setState(() => _grid = false);
          }),
        ],
      ),
    );
  }

  Widget _toggleBtn(IconData icon, bool active, VoidCallback onTap) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        padding: const EdgeInsets.all(6),
        decoration: BoxDecoration(
          color: active ? _canvas : Colors.transparent,
          borderRadius: BorderRadius.circular(8),
        ),
        child: Icon(icon, size: 19, color: active ? _ink : _faint),
      ),
    );
  }

  Widget _collectionsRow() {
    return GestureDetector(
      onTap: widget.onCollections,
      behavior: HitTestBehavior.opaque,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 14, 16, 14),
        child: Row(
          children: <Widget>[
            Container(
              width: 36,
              height: 36,
              decoration: BoxDecoration(
                color: _brand.withValues(alpha: 0.10),
                borderRadius: BorderRadius.circular(10),
              ),
              child: const Icon(Icons.folder_copy_rounded,
                  size: 19, color: _brand),
            ),
            const SizedBox(width: 12),
            const Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    'Collections',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 14.5,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                  SizedBox(height: 1),
                  Text(
                    '4 boards · organise your saves',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            const Icon(Icons.chevron_right_rounded, size: 22, color: _faint),
          ],
        ),
      ),
    );
  }

  Widget _gridView() {
    return GridView.builder(
      padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 14,
        mainAxisSpacing: 18,
        childAspectRatio: 0.49,
      ),
      itemCount: _items.length,
      itemBuilder: (BuildContext context, int i) => _gridCard(_items[i]),
    );
  }

  Widget _gridCard(_Fav f) {
    return GestureDetector(
      onTap: () => widget.onProduct?.call(f.title),
      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/${f.asset}', fit: BoxFit.cover),
                  Positioned(top: 8, right: 8, child: _heart()),
                  if (f.was != null)
                    Positioned(
                      top: 10,
                      left: 0,
                      child: _dropFlag(f.was! - f.price),
                    ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 8),
          Text(
            f.brand.toUpperCase(),
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 10,
              fontWeight: FontWeight.w600,
              letterSpacing: 0.6,
              color: _muted,
            ),
          ),
          const SizedBox(height: 2),
          Text(
            f.title,
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w600,
              color: _ink,
            ),
          ),
          const SizedBox(height: 4),
          _priceRow(f),
          const SizedBox(height: 8),
          _moveBtn(f, full: true),
        ],
      ),
    );
  }

  Widget _listView() {
    return ListView.separated(
      padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
      itemCount: _items.length,
      separatorBuilder: (_, _) =>
          const Divider(height: 28, color: _hairline),
      itemBuilder: (BuildContext context, int i) => _listRow(_items[i]),
    );
  }

  Widget _listRow(_Fav f) {
    return GestureDetector(
      onTap: () => widget.onProduct?.call(f.title),
      behavior: HitTestBehavior.opaque,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          ClipRRect(
            borderRadius: BorderRadius.circular(12),
            child: Stack(
              children: <Widget>[
                Container(
                  width: 92,
                  height: 116,
                  color: _imageBg,
                  child: Image.asset('$_dir/${f.asset}', fit: BoxFit.cover),
                ),
                if (f.was != null)
                  Positioned(top: 8, left: 0, child: _dropFlag(f.was! - f.price)),
              ],
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const SizedBox(height: 2),
                Text(
                  f.brand.toUpperCase(),
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 10,
                    fontWeight: FontWeight.w600,
                    letterSpacing: 0.6,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 3),
                Text(
                  f.title,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 15,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
                const SizedBox(height: 3),
                Text(
                  f.variant,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w500,
                    color: _muted,
                  ),
                ),
                const SizedBox(height: 6),
                _priceRow(f),
                const SizedBox(height: 10),
                Row(
                  children: <Widget>[
                    Expanded(child: _moveBtn(f, full: true)),
                    const SizedBox(width: 10),
                    _heart(),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _priceRow(_Fav f) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.end,
      children: <Widget>[
        Text(
          '\$${f.price}',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 15,
            fontWeight: FontWeight.w800,
            color: f.was != null ? _brand : _ink,
          ),
        ),
        if (f.was != null) ...<Widget>[
          const SizedBox(width: 6),
          Padding(
            padding: const EdgeInsets.only(bottom: 1),
            child: Text(
              '\$${f.was}',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w500,
                decoration: TextDecoration.lineThrough,
                decorationColor: _faint,
                color: _faint,
              ),
            ),
          ),
        ],
      ],
    );
  }

  Widget _moveBtn(_Fav f, {bool full = false}) {
    return SizedBox(
      height: 38,
      width: full ? double.infinity : null,
      child: OutlinedButton(
        onPressed: () => widget.onMoveToBag?.call(f.title),
        style: OutlinedButton.styleFrom(
          foregroundColor: _ink,
          side: const BorderSide(color: _ink, width: 1.3),
          padding: const EdgeInsets.symmetric(horizontal: 12),
          shape:
              RoundedRectangleBorder(borderRadius: BorderRadius.circular(11)),
        ),
        child: const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Icon(Icons.shopping_bag_outlined, size: 16, color: _ink),
            SizedBox(width: 6),
            Text(
              'Move to bag',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w700,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _heart() {
    return Container(
      width: 32,
      height: 32,
      decoration: const BoxDecoration(
        color: _canvas,
        shape: BoxShape.circle,
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: Color(0x1A000000),
            blurRadius: 6,
            offset: Offset(0, 2),
          ),
        ],
      ),
      child: const Icon(Icons.favorite_rounded, size: 17, color: _brand),
    );
  }

  Widget _dropFlag(int saved) {
    return SizedBox(
      width: 74,
      height: 24,
      child: CustomPaint(
        painter: _DropFlagPainter(),
        child: Center(
          child: Padding(
            padding: const EdgeInsets.only(right: 6),
            child: Text(
              '−\$$saved',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 11.5,
                fontWeight: FontWeight.w800,
                color: _canvas,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _Fav {
  const _Fav(this.title, this.brand, this.price, this.was, this.variant,
      this.asset);
  final String title;
  final String brand;
  final int price;
  final int? was;
  final String variant;
  final String asset;
}

/// Paints a price-drop ribbon flag: a rounded-left tag with a notched right
/// tail, filled brand red, sitting flush against the image edge.
class _DropFlagPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;
    const double notch = 8;

    final Path tag = Path()
      ..moveTo(0, 0)
      ..lineTo(w - notch, 0)
      ..lineTo(w, h / 2)
      ..lineTo(w - notch, h)
      ..lineTo(0, h)
      ..close();

    canvas.drawPath(tag, Paint()..color = const Color(0xFFFF385C));

    // Punch-hole on the tail to read as a hanging price tag.
    canvas.drawCircle(
      Offset(w - notch - 5, h / 2),
      2.2,
      Paint()..color = const Color(0x33FFFFFF),
    );
  }

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

Plus bundled 11 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-main

2. AI agent (MCP)

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

FAQ

Can I ship this wishlist screen in a commercial app?

Yes. FlutterKit screens are free for personal and commercial projects under an MIT-style licence — no key, no attribution, no sign-up. Copy the file from this page or run `flutterkit add ecom-wishlist-main` and drop it into any store app.

Does it need any pub packages or fonts?

No packages at all — it's pure `package:flutter/material.dart`, and the price-drop flag is a `CustomPainter` rather than an SVG. The only asset dependency is the Manrope font family (plus the six product webps under `_dir`), and the CLI bundles both and registers them in `pubspec.yaml` for you.

Which Flutter version does this require?

Flutter 3.22 or newer: the Collections tile uses `_brand.withValues(alpha: 0.10)` and the constructor uses `super.key`. On an older 3.x SDK, change that call to `withOpacity(0.10)` and rewrite the constructor as `{Key? key, ...}) : super(key: key)`.

How do I make the heart actually remove an item?

Right now `_heart()` is display-only — it has no tap handler because the widget exposes callbacks rather than mutating its own data. Wrap the `Container` in a `GestureDetector`, pass the `_Fav` in, and either call a new `onUnsave` callback or `setState(() => _items.remove(f))`. Because the header prints `_items.length`, the count updates on the same rebuild.

Where does the price-drop amount come from?

From the model, not the painter. Each `_Fav` has an `int? was`; when it's non-null the card computes `f.was! - f.price` and passes that integer to `_dropFlag`, which prints it as `−$N` over the painted ribbon. To drive it from a server, populate `was` with the price at the time the shopper saved the item and leave it null when nothing changed.

Related screens