E-commerce64 views

How to Build a Clearance Sale Screen in Flutter (Full Code + Preview)

Clearance pages earn their keep by making the discount the hero, not the product photo. This tutorial builds StyleCart's clearance screen in Flutter: a header with a live item count, a horizontal pill bar for three sort options, a tinted savings banner announcing up to 75% off, and a two-column sliver grid of product cards — each with a custom-painted pointed '% OFF' corner flag, a red sale price beside the struck original, and a 'Final sale' warning on cuts of 70% or more.

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

What you'll build

  • A pinned header and horizontal pill sort bar that stay put while only the grid scrolls
  • A savings banner tinted with the brand coral at 8% alpha, with a solid circular sell-icon badge
  • A two-column SliverGrid of clearance cards at a 0.60 aspect ratio, built lazily from an item list
  • A CustomPainter corner flag with a notched tail that draws its own '% OFF' text
  • Pricing rows that pair a red sale price with a struck-through original, plus a conditional 'Final sale' badge at 70%+ discounts

Step-by-step build

1

Create the file

Add a new file at lib/ecom_deals_clearance/ecom_deals_clearance_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.

Tokens, sort labels and the markdown data

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

/// StyleCart — Clearance / Sale.
///
/// Final-markdown grid sorted by biggest discount: a sticky sort/chip bar, a
/// savings banner, then two-column cards with a painted "% OFF" corner flag,
/// struck original pricing and a "Final sale" hint on the deepest cuts.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter flag tags; bundled
/// product webp. Expanded-image grid cards (web-overflow-proof). Callbacks only.
class EcomDealsClearanceScreen extends StatefulWidget {
  const EcomDealsClearanceScreen({
    super.key,
    this.onBack,
    this.onProduct,
  });

  final VoidCallback? onBack;
  final ValueChanged<int>? onProduct;

  @override
  State<EcomDealsClearanceScreen> createState() =>
      _EcomDealsClearanceScreenState();
}

class _EcomDealsClearanceScreenState extends State<EcomDealsClearanceScreen> {
  static const String _dir =
      'lib/screens/ecommerce/ecom_deals_clearance/images';
  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 _surface = Color(0xFFF2F2F2);
  static const Color _imageBg = Color(0xFFF5F5F5);
  static const Color _danger = Color(0xFFE5484D);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const List<String> _sorts = <String>[
    'Biggest discount',
    'Price: low to high',
    'Newest',
  ];

  // pre-sorted by biggest discount
  static const List<_Item> _items = <_Item>[
    _Item('p01', 'Quilted Puffer Jacket', 39, 159, 75),
    _Item('p02', 'Strappy Block Heels', 29, 99, 71),
    _Item('p03', 'Cashmere Scarf', 24, 79, 70),
    _Item('p04', 'Tailored Blazer', 49, 149, 67),
    _Item('p05', 'Pleated Trousers', 27, 75, 64),
    _Item('p06', 'Canvas Sneakers', 25, 65, 62),
    _Item('p07', 'Floral Wrap Dress', 35, 89, 61),
    _Item('p08', 'Structured Handbag', 59, 145, 59),
  ];

  int _sort = 0;

The widget takes only two callbacks — `onBack` and `onProduct` (a `ValueChanged<int>` carrying the tapped index) — so the screen plugs into any backend or router without edits. The palette keeps two reds on purpose: `_brand` `#FF385C` is the promotional coral used for the banner and flags, while `_danger` `#E5484D` is reserved for prices, so 'this is a deal' and 'this price dropped' read as different signals. The eight `_Item` entries are declared already sorted by `pct` descending — the comment says so — which matches the default sort chip, and `int _sort = 0` is the only piece of mutable state on the screen.

Fixed chrome above a sliver scroll region

ecom_deals_clearance_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(),
              _sortBar(),
              Expanded(
                child: CustomScrollView(
                  slivers: <Widget>[
                    SliverToBoxAdapter(child: _savingsBanner()),
                    SliverPadding(
                      padding: const EdgeInsets.fromLTRB(16, 4, 16, 28),
                      sliver: SliverGrid(
                        gridDelegate:
                            const SliverGridDelegateWithFixedCrossAxisCount(
                          crossAxisCount: 2,
                          mainAxisSpacing: 14,
                          crossAxisSpacing: 14,
                          childAspectRatio: 0.60,
                        ),
                        delegate: SliverChildBuilderDelegate(
                          (BuildContext _, int i) => _card(_items[i], i),
                          childCount: _items.length,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the screen carries its own light theme regardless of the host app. The layout is a `Column` where `_header()` and `_sortBar()` sit as plain children — effectively sticky, since only the `Expanded` `CustomScrollView` below them scrolls. Inside it, the savings banner rides in a `SliverToBoxAdapter` so it scrolls away with the grid, and the grid itself is a `SliverGrid` with `crossAxisCount: 2`, 14px gaps and `childAspectRatio: 0.60` — tall cells that leave the image most of the card. `SliverChildBuilderDelegate` builds cards lazily by index, which is the pattern you keep when the list grows past eight items.

A header with a live item count

ecom_deals_clearance_screen.dart
  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 6),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          const Expanded(
            child: Text(
              'Clearance',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 20,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
          const Text(
            '128 items',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12.5,
              fontWeight: FontWeight.w700,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

The header row is asymmetric padding — `fromLTRB(8, 4, 20, 6)` — because the `IconButton` brings its own touch-target whitespace on the left while the '128 items' label needs a real 20px inset on the right. The 'Clearance' title takes 20px `w800` Manrope with `letterSpacing: -0.3` inside an `Expanded`, which shoves the count to the trailing edge. Putting the count in the header rather than above the grid means it stays visible while scrolling, and at 12.5px `_muted` it reads as metadata rather than competing with the title.

The pill sort bar

ecom_deals_clearance_screen.dart
  Widget _sortBar() {
    return SizedBox(
      height: 52,
      child: ListView(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
        children: <Widget>[
          for (int i = 0; i < _sorts.length; i++)
            Padding(
              padding: const EdgeInsets.only(right: 10),
              child: GestureDetector(
                onTap: () => setState(() => _sort = i),
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
                  decoration: BoxDecoration(
                    color: _sort == i ? _ink : _surface,
                    borderRadius: BorderRadius.circular(9999),
                  ),
                  child: Row(
                    children: <Widget>[
                      if (i == 0)
                        Padding(
                          padding: const EdgeInsets.only(right: 5),
                          child: Icon(
                            Icons.swap_vert_rounded,
                            size: 16,
                            color: _sort == i ? _canvas : _ink,
                          ),
                        ),
                      Text(
                        _sorts[i],
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 13,
                          fontWeight: FontWeight.w700,
                          color: _sort == i ? _canvas : _ink,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }

The bar is a fixed 52px `SizedBox` holding a horizontal `ListView`, so extra sort options overflow into a scroll instead of wrapping. A collection-for builds one `GestureDetector` pill per label; tapping runs `setState(() => _sort = i)`, and the selected pill flips from `_surface` grey to solid `_ink` with its text inverting to `_canvas` white — a stronger active state than a border or underline. `BorderRadius.circular(9999)` is the lazy-but-safe way to guarantee a full pill at any height, and only the first chip ('Biggest discount') gets the `Icons.swap_vert_rounded` glyph, marking it as the sort axis rather than a filter.

The savings banner

ecom_deals_clearance_screen.dart
  Widget _savingsBanner() {
    return Container(
      margin: const EdgeInsets.fromLTRB(16, 4, 16, 12),
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      decoration: BoxDecoration(
        color: _brand.withValues(alpha: 0.08),
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: <Widget>[
          Container(
            width: 38,
            height: 38,
            alignment: Alignment.center,
            decoration: const BoxDecoration(
              color: _brand,
              shape: BoxShape.circle,
            ),
            child: const Icon(Icons.sell_rounded, size: 19, color: _canvas),
          ),
          const SizedBox(width: 12),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  'Final markdowns — up to 75% off',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 14,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 2),
                Text(
                  'Limited stock · no returns on clearance',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

The banner's ground is `_brand.withValues(alpha: 0.08)` — the coral knocked back to a wash — while the 38px circular badge holding `Icons.sell_rounded` uses the coral at full strength, so one hue does both the container and its focal point. The copy is two stacked lines inside an `Expanded` column: the headline 'Final markdowns — up to 75% off' at 14px `w800` in `_ink`, and a 12px `_muted` subline carrying the urgency terms ('Limited stock · no returns on clearance'). Selling the ceiling discount here, before any card is seen, frames every price below it.

Product cards: image stack, flag, pricing

ecom_deals_clearance_screen.dart
  Widget _card(_Item it, int i) {
    return GestureDetector(
      onTap: () => widget.onProduct?.call(i),
      child: Container(
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: _hairline),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Expanded(
              child: ClipRRect(
                borderRadius:
                    const BorderRadius.vertical(top: Radius.circular(15)),
                child: Stack(
                  fit: StackFit.expand,
                  children: <Widget>[
                    Container(
                      color: _imageBg,
                      child: Image.asset('$_dir/${it.img}.webp',
                          fit: BoxFit.cover),
                    ),
                    Positioned(
                      left: 0,
                      top: 10,
                      child: CustomPaint(
                        size: const Size(56, 24),
                        painter: _FlagPainter('${it.pct}% OFF'),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(10, 10, 10, 12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    it.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 6),
                  Row(
                    children: <Widget>[
                      Text(
                        '\$${it.price}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 15,
                          fontWeight: FontWeight.w800,
                          color: _danger,
                        ),
                      ),
                      const SizedBox(width: 6),
                      Text(
                        '\$${it.was}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 12,
                          fontWeight: FontWeight.w600,
                          decoration: TextDecoration.lineThrough,
                          color: _muted,
                        ),
                      ),
                    ],
                  ),
                  if (it.pct >= 70) ...<Widget>[
                    const SizedBox(height: 6),
                    const Text(
                      'Final sale',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 10.5,
                        fontWeight: FontWeight.w800,
                        letterSpacing: 0.3,
                        color: _danger,
                      ),
                    ),
                  ],
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

Each card is a hairline-bordered 16px-radius `Container` whose image area is `Expanded` — it absorbs whatever height the 0.60 aspect ratio leaves after the text block, which is what makes the grid web-overflow-proof. The image sits in a `Stack` under a `ClipRRect` rounded to 15 (one less than the card, so the border stays visible), with the `CustomPaint` flag `Positioned` at `left: 0, top: 10` in a fixed 56×24 box. The pricing row leads with the sale price at 15px `w800` in `_danger`, then the original at only 12px with `TextDecoration.lineThrough` in `_muted` — size, weight and colour all rank the two numbers, not just the strike. The kicker is `if (it.pct >= 70) ...[...]`: a spread-in 'Final sale' label in 10.5px `_danger` caps that appears only on the three deepest cuts, echoing the banner's no-returns warning exactly where it applies.

The item model and the flag painter

ecom_deals_clearance_screen.dart
class _Item {
  const _Item(this.img, this.title, this.price, this.was, this.pct);
  final String img;
  final String title;
  final int price;
  final int was;
  final int pct;
}

/// A pointed corner flag (banner with a notched tail) carrying the discount %.
class _FlagPainter extends CustomPainter {
  _FlagPainter(this.label);
  final String label;

  static const Color _brand = Color(0xFFFF385C);

  @override
  void paint(Canvas canvas, Size size) {
    final Path flag = Path()
      ..moveTo(0, 0)
      ..lineTo(size.width, 0)
      ..lineTo(size.width - 7, size.height / 2)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();
    canvas.drawPath(flag, Paint()..color = _brand);

    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: label,
        style: const TextStyle(
          fontFamily: 'Manrope',
          fontSize: 11,
          fontWeight: FontWeight.w800,
          color: Colors.white,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout(maxWidth: size.width - 8);
    tp.paint(
      canvas,
      Offset(6, (size.height - tp.height) / 2),
    );
  }

  @override
  bool shouldRepaint(_FlagPainter old) => old.label != label;
}

`_Item` is a five-field const value class — image key, title, sale price, original price and discount percentage — with prices as `int`s since clearance tags round to whole dollars. `_FlagPainter` draws the banner as a single closed `Path`: across the top, then `lineTo(size.width - 7, size.height / 2)` cuts 7px inward at mid-height before returning to the bottom-right corner, which is what produces the notched swallow-tail on the right edge. The label is rendered by the painter itself via `TextPainter` at 11px `w800` white Manrope, laid out with `maxWidth: size.width - 8` and painted at `Offset(6, ...)` to centre vertically — no child widget needed. `shouldRepaint` compares labels, so the eight flags never repaint unless their text 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 — Clearance / Sale.
///
/// Final-markdown grid sorted by biggest discount: a sticky sort/chip bar, a
/// savings banner, then two-column cards with a painted "% OFF" corner flag,
/// struck original pricing and a "Final sale" hint on the deepest cuts.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter flag tags; bundled
/// product webp. Expanded-image grid cards (web-overflow-proof). Callbacks only.
class EcomDealsClearanceScreen extends StatefulWidget {
  const EcomDealsClearanceScreen({
    super.key,
    this.onBack,
    this.onProduct,
  });

  final VoidCallback? onBack;
  final ValueChanged<int>? onProduct;

  @override
  State<EcomDealsClearanceScreen> createState() =>
      _EcomDealsClearanceScreenState();
}

class _EcomDealsClearanceScreenState extends State<EcomDealsClearanceScreen> {
  static const String _dir =
      'lib/screens/ecommerce/ecom_deals_clearance/images';
  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 _surface = Color(0xFFF2F2F2);
  static const Color _imageBg = Color(0xFFF5F5F5);
  static const Color _danger = Color(0xFFE5484D);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const List<String> _sorts = <String>[
    'Biggest discount',
    'Price: low to high',
    'Newest',
  ];

  // pre-sorted by biggest discount
  static const List<_Item> _items = <_Item>[
    _Item('p01', 'Quilted Puffer Jacket', 39, 159, 75),
    _Item('p02', 'Strappy Block Heels', 29, 99, 71),
    _Item('p03', 'Cashmere Scarf', 24, 79, 70),
    _Item('p04', 'Tailored Blazer', 49, 149, 67),
    _Item('p05', 'Pleated Trousers', 27, 75, 64),
    _Item('p06', 'Canvas Sneakers', 25, 65, 62),
    _Item('p07', 'Floral Wrap Dress', 35, 89, 61),
    _Item('p08', 'Structured Handbag', 59, 145, 59),
  ];

  int _sort = 0;

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              _sortBar(),
              Expanded(
                child: CustomScrollView(
                  slivers: <Widget>[
                    SliverToBoxAdapter(child: _savingsBanner()),
                    SliverPadding(
                      padding: const EdgeInsets.fromLTRB(16, 4, 16, 28),
                      sliver: SliverGrid(
                        gridDelegate:
                            const SliverGridDelegateWithFixedCrossAxisCount(
                          crossAxisCount: 2,
                          mainAxisSpacing: 14,
                          crossAxisSpacing: 14,
                          childAspectRatio: 0.60,
                        ),
                        delegate: SliverChildBuilderDelegate(
                          (BuildContext _, int i) => _card(_items[i], i),
                          childCount: _items.length,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(8, 4, 20, 6),
      child: Row(
        children: <Widget>[
          IconButton(
            onPressed: widget.onBack,
            icon: const Icon(Icons.arrow_back_rounded, size: 22, color: _ink),
          ),
          const Expanded(
            child: Text(
              'Clearance',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 20,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
          const Text(
            '128 items',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 12.5,
              fontWeight: FontWeight.w700,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

  Widget _sortBar() {
    return SizedBox(
      height: 52,
      child: ListView(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
        children: <Widget>[
          for (int i = 0; i < _sorts.length; i++)
            Padding(
              padding: const EdgeInsets.only(right: 10),
              child: GestureDetector(
                onTap: () => setState(() => _sort = i),
                child: Container(
                  padding:
                      const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
                  decoration: BoxDecoration(
                    color: _sort == i ? _ink : _surface,
                    borderRadius: BorderRadius.circular(9999),
                  ),
                  child: Row(
                    children: <Widget>[
                      if (i == 0)
                        Padding(
                          padding: const EdgeInsets.only(right: 5),
                          child: Icon(
                            Icons.swap_vert_rounded,
                            size: 16,
                            color: _sort == i ? _canvas : _ink,
                          ),
                        ),
                      Text(
                        _sorts[i],
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 13,
                          fontWeight: FontWeight.w700,
                          color: _sort == i ? _canvas : _ink,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }

  Widget _savingsBanner() {
    return Container(
      margin: const EdgeInsets.fromLTRB(16, 4, 16, 12),
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      decoration: BoxDecoration(
        color: _brand.withValues(alpha: 0.08),
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: <Widget>[
          Container(
            width: 38,
            height: 38,
            alignment: Alignment.center,
            decoration: const BoxDecoration(
              color: _brand,
              shape: BoxShape.circle,
            ),
            child: const Icon(Icons.sell_rounded, size: 19, color: _canvas),
          ),
          const SizedBox(width: 12),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  'Final markdowns — up to 75% off',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 14,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 2),
                Text(
                  'Limited stock · no returns on clearance',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _card(_Item it, int i) {
    return GestureDetector(
      onTap: () => widget.onProduct?.call(i),
      child: Container(
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: _hairline),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Expanded(
              child: ClipRRect(
                borderRadius:
                    const BorderRadius.vertical(top: Radius.circular(15)),
                child: Stack(
                  fit: StackFit.expand,
                  children: <Widget>[
                    Container(
                      color: _imageBg,
                      child: Image.asset('$_dir/${it.img}.webp',
                          fit: BoxFit.cover),
                    ),
                    Positioned(
                      left: 0,
                      top: 10,
                      child: CustomPaint(
                        size: const Size(56, 24),
                        painter: _FlagPainter('${it.pct}% OFF'),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(10, 10, 10, 12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    it.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13,
                      fontWeight: FontWeight.w700,
                      color: _ink,
                    ),
                  ),
                  const SizedBox(height: 6),
                  Row(
                    children: <Widget>[
                      Text(
                        '\$${it.price}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 15,
                          fontWeight: FontWeight.w800,
                          color: _danger,
                        ),
                      ),
                      const SizedBox(width: 6),
                      Text(
                        '\$${it.was}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 12,
                          fontWeight: FontWeight.w600,
                          decoration: TextDecoration.lineThrough,
                          color: _muted,
                        ),
                      ),
                    ],
                  ),
                  if (it.pct >= 70) ...<Widget>[
                    const SizedBox(height: 6),
                    const Text(
                      'Final sale',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 10.5,
                        fontWeight: FontWeight.w800,
                        letterSpacing: 0.3,
                        color: _danger,
                      ),
                    ),
                  ],
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _Item {
  const _Item(this.img, this.title, this.price, this.was, this.pct);
  final String img;
  final String title;
  final int price;
  final int was;
  final int pct;
}

/// A pointed corner flag (banner with a notched tail) carrying the discount %.
class _FlagPainter extends CustomPainter {
  _FlagPainter(this.label);
  final String label;

  static const Color _brand = Color(0xFFFF385C);

  @override
  void paint(Canvas canvas, Size size) {
    final Path flag = Path()
      ..moveTo(0, 0)
      ..lineTo(size.width, 0)
      ..lineTo(size.width - 7, size.height / 2)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();
    canvas.drawPath(flag, Paint()..color = _brand);

    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: label,
        style: const TextStyle(
          fontFamily: 'Manrope',
          fontSize: 11,
          fontWeight: FontWeight.w800,
          color: Colors.white,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout(maxWidth: size.width - 8);
    tp.paint(
      canvas,
      Offset(6, (size.height - tp.height) / 2),
    );
  }

  @override
  bool shouldRepaint(_FlagPainter old) => old.label != label;
}

Plus bundled 13 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-deals-clearance

2. AI agent (MCP)

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

FAQ

Is this clearance screen free to use in a commercial app?

Yes. FlutterKit screens are free to use, commercially included — ship this grid in a paid retail or marketplace app as-is or restyled. No attribution or sign-up is required.

What packages or fonts does this screen depend on?

No pub packages at all — the imports stop at `package:flutter/material.dart`, and the corner flag is a hand-rolled `CustomPainter` rather than an SVG library. The only assets are the bundled Manrope font (referenced by `fontFamily: 'Manrope'`, so declare it in `pubspec.yaml`) and the product `.webp` images loaded from the `_dir` path.

Which Flutter version does this need?

Flutter 3.27 or newer, because the savings banner tints its background with `_brand.withValues(alpha: 0.08)`. On an older SDK, replace that call with `withOpacity(0.08)`; the constructor also uses `super.key`, which needs Dart 2.17+ (Flutter 3.0+).

The sort pills change state — why doesn't the grid re-order?

The demo data is a `static const` list pre-sorted by biggest discount, so `_sort` only drives the chip styling. To make it real, keep `_items` as the source, derive a sorted copy in `build` — switch on `_sort` and compare `pct` descending, `price` ascending, or your added date field — and hand that list to the `SliverChildBuilderDelegate`. `setState` in the pill's `onTap` already triggers the rebuild.

How do I plug in real products instead of the eight sample items?

Replace the `_Item` list with your API models — you need a title, sale price, original price, discount percent and an image reference. Swap `Image.asset('$_dir/${it.img}.webp')` for `Image.network` (keep the `_imageBg` container behind it as a loading ground), and compute `pct` from the two prices so the flag and the 70% 'Final sale' threshold stay honest.

Related screens