E-commerce64 views

How to Build a Coupon Wallet Screen in Flutter (Full Code + Preview)

Every shopping app eventually needs a coupon wallet, and a flat list of grey cards undersells the discounts it holds. This tutorial builds StyleCart's My Coupons screen in Flutter: Available, Used and Expired segmented tabs over ticket-shaped cards painted with a dashed perforation, side notches and a left accent stripe. Each ticket shows the discount value, a spaced-out code pill, an expiry line, a Copy chip that flips to Copied, and terms that expand inline. You end with one self-contained file, no packages.

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

What you'll build

  • ✓Ticket-shaped coupon cards drawn by a CustomPainter — dashed perforation, punched side notches and a 5px accent stripe
  • ✓An Available / Used / Expired segmented control with an elevated white thumb on the active tab
  • ✓A Copy chip that swaps its icon and label to a green Copied state per coupon code
  • ✓An expandable Terms & conditions row driven by a single open-index integer
  • ✓Automatic dimming of used and expired tickets, with the accent stripe fading to grey

Step-by-step build

1

Create the file

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

One widget, one palette, three tab labels

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

/// StyleCart — My Coupons.
///
/// Available / Used / Expired segmented tabs over painted coupon tickets
/// (dashed perforation + side notches), each with a copyable code, value, min
/// spend, expiry and an expandable terms line.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics.
/// Exposes callbacks only.
class EcomOffersCouponsScreen extends StatefulWidget {
  const EcomOffersCouponsScreen({super.key, this.onBack});

  final VoidCallback? onBack;

  @override
  State<EcomOffersCouponsScreen> createState() =>
      _EcomOffersCouponsScreenState();
}

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

  static const List<String> _tabs = <String>['Available', 'Used', 'Expired'];

`EcomOffersCouponsScreen` is a `StatefulWidget` taking only an optional `onBack` callback — the wallet owns its tab, copy and terms state internally, so the host app wires nothing else. The State class opens with the token palette: `_brand` is the Airbnb-style coral `#FF385C`, `_ink` a near-black `#222222` for headings, and three greys do distinct jobs — `_muted` `#6A6A6A` for secondary text, `_faint` `#C1C1C1` for icons and dead coupons, `_hairline` `#EBEBEB` for borders. `_surface` `#F2F2F2` matters more than it looks: it is both the page background and the colour the painter later uses to fake the punched notches. The three tab labels sit in a `static const List<String> _tabs`, so the segmented control and the empty-state headline can both read from the same source.

Coupons as a list-of-lists, plus three state variables

ecom_offers_coupons_screen.dart
  static const List<List<_Coupon>> _data = <List<_Coupon>>[
    <_Coupon>[
      _Coupon('STYLE20', '20% OFF', 'On orders above \$80', 'Expires 30 Jun',
          'Max discount \$40. Valid on full-price items only.'),
      _Coupon('FREESHIP', 'FREE SHIPPING', 'No minimum spend', 'Expires 28 Jun',
          'Applied automatically at checkout for standard delivery.'),
      _Coupon('NEW15', '\$15 OFF', 'First order over \$60', 'Expires 12 Jul',
          'One use per account. Cannot be combined with other coupons.'),
    ],
    <_Coupon>[
      _Coupon('SUMMER10', '10% OFF', 'Used on order #SC-90412', 'Used 09 Jun',
          'Redeemed against a \$118 order. Saved \$11.80.'),
    ],
    <_Coupon>[
      _Coupon('SPRING25', '25% OFF', 'On orders above \$100', 'Expired 31 May',
          'This coupon is no longer valid.'),
      _Coupon('WELCOME5', '\$5 OFF', 'Any order', 'Expired 20 May',
          'This coupon is no longer valid.'),
    ],
  ];

  int _tab = 0;
  String? _copied;
  int _openTerms = -1;

`_data` is a `List<List<_Coupon>>` whose outer index mirrors the tab order — index 0 holds three available coupons (STYLE20, FREESHIP, NEW15), index 1 the single used SUMMER10, index 2 two expired ones. That shape makes tab switching a plain lookup, `_data[_tab]`, with no filtering logic anywhere. Each `_Coupon` is five strings: code, value, sub-line, expiry and terms — and the sample copy is written per lifecycle stage, so the used coupon's sub-line becomes 'Used on order #SC-90412' and its terms report the \$11.80 saved, while expired ones just say the coupon is no longer valid. All mutable state is three fields: `_tab` (selected segment), `_copied` (which code the chip has confirmed), and `_openTerms` (index of the one expanded terms panel, -1 for none).

A white header block floating on a grey page

ecom_offers_coupons_screen.dart
  @override
  Widget build(BuildContext context) {
    final List<_Coupon> list = _data[_tab];
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _surface,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              ColoredBox(
                color: _canvas,
                child: Column(
                  children: <Widget>[
                    _header(),
                    _tabBar(),
                  ],
                ),
              ),
              Expanded(
                child: list.isEmpty
                    ? _emptyState()
                    : ListView.builder(
                        padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
                        itemCount: list.length,
                        itemBuilder: (BuildContext _, int i) =>
                            _couponCard(list[i], i),
                      ),
              ),
            ],
          ),
        ),
      ),
    );
  }

`build` resolves `_data[_tab]` once, then lays out a `Scaffold` whose background is grey `_surface` rather than white. The header and tab bar are wrapped together in a `ColoredBox(color: _canvas)`, which is what creates the app's visual split: a white chrome band up top, grey content region below — and because the tickets are painted white, they pop against that grey without needing shadows. The list region is an `Expanded` that ternaries between `_emptyState()` and a `ListView.builder`; the builder gets `EdgeInsets.fromLTRB(20, 16, 20, 28)` so the last ticket clears the screen bottom, and each item call passes the index through so the card can compare itself against `_openTerms`. Note the screen also wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` — it carries its own theme instead of inheriting the host's.

Header and the segmented tab control

ecom_offers_coupons_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 Text(
            'My coupons',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 20,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.3,
              color: _ink,
            ),
          ),
        ],
      ),
    );
  }

  Widget _tabBar() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
      child: Container(
        padding: const EdgeInsets.all(4),
        decoration: BoxDecoration(
          color: _surface,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Row(
          children: <Widget>[
            for (int i = 0; i < _tabs.length; i++)
              Expanded(
                child: GestureDetector(
                  onTap: () => setState(() {
                    _tab = i;
                    _openTerms = -1;
                  }),
                  child: Container(
                    height: 38,
                    alignment: Alignment.center,
                    decoration: BoxDecoration(
                      color: _tab == i ? _canvas : Colors.transparent,
                      borderRadius: BorderRadius.circular(9),
                      boxShadow: _tab == i
                          ? <BoxShadow>[
                              BoxShadow(
                                color: Colors.black.withValues(alpha: 0.06),
                                blurRadius: 6,
                                offset: const Offset(0, 2),
                              ),
                            ]
                          : null,
                    ),
                    child: Text(
                      _tabs[i],
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w700,
                        color: _tab == i ? _ink : _muted,
                      ),
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }

The header is a back `IconButton` firing `widget.onBack` next to a 20px `w800` 'My coupons' title with `letterSpacing: -0.3` — left-aligned in the iOS large-title spirit, not centred. The tab bar is the hand-built segmented control: a grey `_surface` container with 4px inner padding and radius 12, holding three `Expanded` `GestureDetector`s from a collection-for. The selected segment gets a white fill at radius 9 (12 minus the 4px inset keeps the corners concentric) plus a soft thumb shadow — `Colors.black.withValues(alpha: 0.06)`, blur 6, offset (0, 2) — while unselected ones stay transparent with `_muted` labels. The `onTap` does two things at once: sets `_tab = i` and resets `_openTerms = -1`, so an expanded terms panel never carries its index over to a different tab's list, where it would open the wrong coupon.

The coupon ticket card

ecom_offers_coupons_screen.dart
  Widget _couponCard(_Coupon c, int i) {
    final bool active = _tab == 0;
    final bool open = _openTerms == i;
    return Opacity(
      opacity: active ? 1 : 0.62,
      child: Container(
        margin: const EdgeInsets.only(bottom: 14),
        child: CustomPaint(
          painter: _TicketPainter(active ? _brand : _faint),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(
                            c.value,
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 22,
                              fontWeight: FontWeight.w800,
                              letterSpacing: -0.5,
                              color: active ? _brand : _muted,
                            ),
                          ),
                          const SizedBox(height: 2),
                          Text(
                            c.sub,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w600,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                    if (active) _copyChip(c.code),
                  ],
                ),
                const SizedBox(height: 14),
                Row(
                  children: <Widget>[
                    _codePill(c.code, active),
                    const Spacer(),
                    Icon(Icons.schedule_rounded, size: 14, color: _faint),
                    const SizedBox(width: 4),
                    Text(
                      c.expiry,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w700,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                GestureDetector(
                  onTap: () => setState(() => _openTerms = open ? -1 : i),
                  behavior: HitTestBehavior.opaque,
                  child: Row(
                    children: <Widget>[
                      Text(
                        'Terms & conditions',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 12,
                          fontWeight: FontWeight.w700,
                          color: active ? _brand : _muted,
                        ),
                      ),
                      Icon(
                        open
                            ? Icons.keyboard_arrow_up_rounded
                            : Icons.keyboard_arrow_down_rounded,
                        size: 18,
                        color: active ? _brand : _muted,
                      ),
                    ],
                  ),
                ),
                if (open)
                  Padding(
                    padding: const EdgeInsets.only(top: 6),
                    child: Text(
                      c.terms,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w500,
                        height: 1.4,
                        color: _muted,
                      ),
                    ),
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }

`_couponCard` derives `active` from `_tab == 0` — the tab itself, not a flag on the coupon, decides whether a ticket is live. Inactive cards are wrapped in `Opacity(opacity: 0.62)` and hand the painter `_faint` grey instead of `_brand` coral, so used and expired tickets dim and lose their stripe in one stroke. The card body is a `CustomPaint` with 16px padding — the painter draws the ticket shape behind ordinary layout widgets. Top row: the 22px `w800` value ('20% OFF') in coral when active, the min-spend sub-line beneath, and `if (active) _copyChip(c.code)` on the right — dead coupons simply have no copy button rather than a disabled one. The middle row pairs `_codePill` with a `Spacer`, then a 14px `schedule_rounded` clock and the expiry text. The 'Terms & conditions' row is a `GestureDetector` with `HitTestBehavior.opaque` (so taps land in the gaps between text and chevron), toggling `_openTerms` between this index and -1; `if (open)` then inserts the terms paragraph at 12px with `height: 1.4`.

Copy chip and code pill

ecom_offers_coupons_screen.dart
  Widget _copyChip(String code) {
    final bool done = _copied == code;
    return GestureDetector(
      onTap: () => setState(() => _copied = code),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
        decoration: BoxDecoration(
          color: done ? _success : _brand,
          borderRadius: BorderRadius.circular(9999),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(done ? Icons.check_rounded : Icons.copy_rounded,
                size: 14, color: _canvas),
            const SizedBox(width: 5),
            Text(
              done ? 'Copied' : 'Copy',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w800,
                color: _canvas,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _codePill(String code, bool active) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(8),
        border: Border.all(
          color: active ? _brand.withValues(alpha: 0.4) : _hairline,
        ),
      ),
      child: Text(
        code,
        style: const TextStyle(
          fontFamily: _font,
          fontSize: 12.5,
          fontWeight: FontWeight.w800,
          letterSpacing: 1.0,
          color: _ink,
        ),
      ),
    );
  }

`_copyChip` compares `_copied == code` to pick its look: coral pill with `copy_rounded` and 'Copy' before tapping, `_success` green `#2E9E5B` with `check_rounded` and 'Copied' after. Because `_copied` stores the code string rather than a boolean, copying a second coupon un-confirms the first — only one chip is ever green. Its `borderRadius: BorderRadius.circular(9999)` is the give-me-a-full-pill trick, and `mainAxisSize: MainAxisSize.min` keeps it hugging its content inside the card row. `_codePill` renders the code itself as a grey `_surface` box whose border is the state cue: `_brand.withValues(alpha: 0.4)` for available coupons, plain `_hairline` otherwise. The code text uses `letterSpacing: 1.0` at 12.5px `w800` — spacing out the glyphs makes STYLE20 read as a code to be transcribed, not a word.

Empty state and the coupon model

ecom_offers_coupons_screen.dart
  Widget _emptyState() {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(40),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Container(
              width: 84,
              height: 84,
              alignment: Alignment.center,
              decoration: const BoxDecoration(
                color: _canvas,
                shape: BoxShape.circle,
              ),
              child: const Icon(Icons.local_offer_outlined,
                  size: 36, color: _faint),
            ),
            const SizedBox(height: 18),
            Text(
              'No ${_tabs[_tab].toLowerCase()} coupons',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 16,
                fontWeight: FontWeight.w800,
                color: _ink,
              ),
            ),
            const SizedBox(height: 6),
            const Text(
              'Coupons you collect will appear here.',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w500,
                color: _muted,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _Coupon {
  const _Coupon(this.code, this.value, this.sub, this.expiry, this.terms);
  final String code;
  final String value;
  final String sub;
  final String expiry;
  final String terms;
}

The empty state earns its place because the Used and Expired tabs can plausibly be empty in a real account. An 84px white circle holds a 36px `local_offer_outlined` tag icon in `_faint`, and the headline interpolates the current tab label — `'No ${_tabs[_tab].toLowerCase()} coupons'` — so it reads 'No used coupons' or 'No expired coupons' without any extra copy strings. The sub-line 'Coupons you collect will appear here.' tells the shopper the tab is functional, just unpopulated. `_Coupon` itself is a five-field `const` value class with a positional constructor, which is what let the `_data` table up top stay compact enough to scan.

Painting the ticket: stripe, notches, perforation

ecom_offers_coupons_screen.dart
/// A coupon-ticket card: white rounded body with side notches and a vertical
/// dashed perforation near the right edge, hinting the tear-off stub.
class _TicketPainter extends CustomPainter {
  _TicketPainter(this.accent);
  final Color accent;

  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _hairline = Color(0xFFEBEBEB);

  @override
  void paint(Canvas canvas, Size size) {
    const double r = 16;
    final RRect body = RRect.fromRectAndRadius(
      Offset.zero & size,
      const Radius.circular(r),
    );
    canvas.drawRRect(body, Paint()..color = _canvas);
    canvas.drawRRect(
      body,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1
        ..color = _hairline,
    );

    // accent stripe on the left edge
    canvas.save();
    canvas.clipRRect(body);
    canvas.drawRect(
      Rect.fromLTWH(0, 0, 5, size.height),
      Paint()..color = accent,
    );
    canvas.restore();

    // side notches at ~70% width
    final double nx = size.width * 0.74;
    const double nr = 9;
    final Paint notch = Paint()..color = const Color(0xFFF2F2F2);
    canvas.drawCircle(Offset(nx, 0), nr, notch);
    canvas.drawCircle(Offset(nx, size.height), nr, notch);

    // dashed perforation between the notches
    final Paint dash = Paint()
      ..color = _hairline
      ..strokeWidth = 1.4
      ..strokeCap = StrokeCap.round;
    double y = nr + 4;
    while (y < size.height - nr - 4) {
      canvas.drawLine(Offset(nx, y), Offset(nx, y + 5), dash);
      y += 10;
    }
  }

  @override
  bool shouldRepaint(_TicketPainter old) => old.accent != accent;
}

`_TicketPainter` takes the accent colour as its one constructor argument, so the same painter renders coral live tickets and grey dead ones. It first draws the white body `RRect` at radius 16 with a 1px `_hairline` stroke. The accent stripe is a plain 5px-wide `drawRect` down the left edge — but it is preceded by `canvas.save()` and `clipRRect(body)`, which is what curves the stripe's corners to match the card instead of poking square ends past the radius. The ticket illusion comes next: two 9px circles filled with `#F2F2F2` — exactly the page background — are drawn straddling the top and bottom edges at 74% of the width, so they read as punched notches rather than dots. Between them, a `while` loop walks down in 10px steps drawing 5px `_hairline` segments with `StrokeCap.round`, producing the dashed perforation of a tear-off stub. `shouldRepaint` compares accents, so a card only repaints when its colour actually 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 — My Coupons.
///
/// Available / Used / Expired segmented tabs over painted coupon tickets
/// (dashed perforation + side notches), each with a copyable code, value, min
/// spend, expiry and an expandable terms line.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics.
/// Exposes callbacks only.
class EcomOffersCouponsScreen extends StatefulWidget {
  const EcomOffersCouponsScreen({super.key, this.onBack});

  final VoidCallback? onBack;

  @override
  State<EcomOffersCouponsScreen> createState() =>
      _EcomOffersCouponsScreenState();
}

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

  static const List<String> _tabs = <String>['Available', 'Used', 'Expired'];

  static const List<List<_Coupon>> _data = <List<_Coupon>>[
    <_Coupon>[
      _Coupon('STYLE20', '20% OFF', 'On orders above \$80', 'Expires 30 Jun',
          'Max discount \$40. Valid on full-price items only.'),
      _Coupon('FREESHIP', 'FREE SHIPPING', 'No minimum spend', 'Expires 28 Jun',
          'Applied automatically at checkout for standard delivery.'),
      _Coupon('NEW15', '\$15 OFF', 'First order over \$60', 'Expires 12 Jul',
          'One use per account. Cannot be combined with other coupons.'),
    ],
    <_Coupon>[
      _Coupon('SUMMER10', '10% OFF', 'Used on order #SC-90412', 'Used 09 Jun',
          'Redeemed against a \$118 order. Saved \$11.80.'),
    ],
    <_Coupon>[
      _Coupon('SPRING25', '25% OFF', 'On orders above \$100', 'Expired 31 May',
          'This coupon is no longer valid.'),
      _Coupon('WELCOME5', '\$5 OFF', 'Any order', 'Expired 20 May',
          'This coupon is no longer valid.'),
    ],
  ];

  int _tab = 0;
  String? _copied;
  int _openTerms = -1;

  @override
  Widget build(BuildContext context) {
    final List<_Coupon> list = _data[_tab];
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _surface,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              ColoredBox(
                color: _canvas,
                child: Column(
                  children: <Widget>[
                    _header(),
                    _tabBar(),
                  ],
                ),
              ),
              Expanded(
                child: list.isEmpty
                    ? _emptyState()
                    : ListView.builder(
                        padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
                        itemCount: list.length,
                        itemBuilder: (BuildContext _, int i) =>
                            _couponCard(list[i], i),
                      ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  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 Text(
            'My coupons',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 20,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.3,
              color: _ink,
            ),
          ),
        ],
      ),
    );
  }

  Widget _tabBar() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
      child: Container(
        padding: const EdgeInsets.all(4),
        decoration: BoxDecoration(
          color: _surface,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Row(
          children: <Widget>[
            for (int i = 0; i < _tabs.length; i++)
              Expanded(
                child: GestureDetector(
                  onTap: () => setState(() {
                    _tab = i;
                    _openTerms = -1;
                  }),
                  child: Container(
                    height: 38,
                    alignment: Alignment.center,
                    decoration: BoxDecoration(
                      color: _tab == i ? _canvas : Colors.transparent,
                      borderRadius: BorderRadius.circular(9),
                      boxShadow: _tab == i
                          ? <BoxShadow>[
                              BoxShadow(
                                color: Colors.black.withValues(alpha: 0.06),
                                blurRadius: 6,
                                offset: const Offset(0, 2),
                              ),
                            ]
                          : null,
                    ),
                    child: Text(
                      _tabs[i],
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 13.5,
                        fontWeight: FontWeight.w700,
                        color: _tab == i ? _ink : _muted,
                      ),
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget _couponCard(_Coupon c, int i) {
    final bool active = _tab == 0;
    final bool open = _openTerms == i;
    return Opacity(
      opacity: active ? 1 : 0.62,
      child: Container(
        margin: const EdgeInsets.only(bottom: 14),
        child: CustomPaint(
          painter: _TicketPainter(active ? _brand : _faint),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(
                            c.value,
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 22,
                              fontWeight: FontWeight.w800,
                              letterSpacing: -0.5,
                              color: active ? _brand : _muted,
                            ),
                          ),
                          const SizedBox(height: 2),
                          Text(
                            c.sub,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w600,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ),
                    if (active) _copyChip(c.code),
                  ],
                ),
                const SizedBox(height: 14),
                Row(
                  children: <Widget>[
                    _codePill(c.code, active),
                    const Spacer(),
                    Icon(Icons.schedule_rounded, size: 14, color: _faint),
                    const SizedBox(width: 4),
                    Text(
                      c.expiry,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w700,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                GestureDetector(
                  onTap: () => setState(() => _openTerms = open ? -1 : i),
                  behavior: HitTestBehavior.opaque,
                  child: Row(
                    children: <Widget>[
                      Text(
                        'Terms & conditions',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 12,
                          fontWeight: FontWeight.w700,
                          color: active ? _brand : _muted,
                        ),
                      ),
                      Icon(
                        open
                            ? Icons.keyboard_arrow_up_rounded
                            : Icons.keyboard_arrow_down_rounded,
                        size: 18,
                        color: active ? _brand : _muted,
                      ),
                    ],
                  ),
                ),
                if (open)
                  Padding(
                    padding: const EdgeInsets.only(top: 6),
                    child: Text(
                      c.terms,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w500,
                        height: 1.4,
                        color: _muted,
                      ),
                    ),
                  ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _copyChip(String code) {
    final bool done = _copied == code;
    return GestureDetector(
      onTap: () => setState(() => _copied = code),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
        decoration: BoxDecoration(
          color: done ? _success : _brand,
          borderRadius: BorderRadius.circular(9999),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Icon(done ? Icons.check_rounded : Icons.copy_rounded,
                size: 14, color: _canvas),
            const SizedBox(width: 5),
            Text(
              done ? 'Copied' : 'Copy',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w800,
                color: _canvas,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _codePill(String code, bool active) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(8),
        border: Border.all(
          color: active ? _brand.withValues(alpha: 0.4) : _hairline,
        ),
      ),
      child: Text(
        code,
        style: const TextStyle(
          fontFamily: _font,
          fontSize: 12.5,
          fontWeight: FontWeight.w800,
          letterSpacing: 1.0,
          color: _ink,
        ),
      ),
    );
  }

  Widget _emptyState() {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(40),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Container(
              width: 84,
              height: 84,
              alignment: Alignment.center,
              decoration: const BoxDecoration(
                color: _canvas,
                shape: BoxShape.circle,
              ),
              child: const Icon(Icons.local_offer_outlined,
                  size: 36, color: _faint),
            ),
            const SizedBox(height: 18),
            Text(
              'No ${_tabs[_tab].toLowerCase()} coupons',
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 16,
                fontWeight: FontWeight.w800,
                color: _ink,
              ),
            ),
            const SizedBox(height: 6),
            const Text(
              'Coupons you collect will appear here.',
              textAlign: TextAlign.center,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w500,
                color: _muted,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _Coupon {
  const _Coupon(this.code, this.value, this.sub, this.expiry, this.terms);
  final String code;
  final String value;
  final String sub;
  final String expiry;
  final String terms;
}

/// A coupon-ticket card: white rounded body with side notches and a vertical
/// dashed perforation near the right edge, hinting the tear-off stub.
class _TicketPainter extends CustomPainter {
  _TicketPainter(this.accent);
  final Color accent;

  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _hairline = Color(0xFFEBEBEB);

  @override
  void paint(Canvas canvas, Size size) {
    const double r = 16;
    final RRect body = RRect.fromRectAndRadius(
      Offset.zero & size,
      const Radius.circular(r),
    );
    canvas.drawRRect(body, Paint()..color = _canvas);
    canvas.drawRRect(
      body,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1
        ..color = _hairline,
    );

    // accent stripe on the left edge
    canvas.save();
    canvas.clipRRect(body);
    canvas.drawRect(
      Rect.fromLTWH(0, 0, 5, size.height),
      Paint()..color = accent,
    );
    canvas.restore();

    // side notches at ~70% width
    final double nx = size.width * 0.74;
    const double nr = 9;
    final Paint notch = Paint()..color = const Color(0xFFF2F2F2);
    canvas.drawCircle(Offset(nx, 0), nr, notch);
    canvas.drawCircle(Offset(nx, size.height), nr, notch);

    // dashed perforation between the notches
    final Paint dash = Paint()
      ..color = _hairline
      ..strokeWidth = 1.4
      ..strokeCap = StrokeCap.round;
    double y = nr + 4;
    while (y < size.height - nr - 4) {
      canvas.drawLine(Offset(nx, y), Offset(nx, y + 5), dash);
      y += 10;
    }
  }

  @override
  bool shouldRepaint(_TicketPainter old) => old.accent != accent;
}

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

Two faster ways to add it

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

1. FlutterKit CLI

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

$ flutterkit add ecom-offers-coupons

2. AI agent (MCP)

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

FAQ

Can I use this coupon wallet screen in a commercial app?

Yes. FlutterKit screens are free, commercial use included — copy the code from this page or install it via the CLI, ship it in a paid app, and you owe no licence key and no attribution.

Does this screen need any packages or icon fonts?

No third-party packages at all — it is pure Flutter, and even the ticket shape is a CustomPainter rather than an SVG asset. The only bundled resource is the Manrope font family (Regular through ExtraBold), which the install adds under the screen's own fonts folder.

Which Flutter version does this require?

Flutter 3.22 or newer: the tab thumb shadow and the code-pill border both use `Color.withValues(alpha: ...)`, and the constructors use super parameters. On an older SDK, replace each `withValues(alpha: x)` with `withOpacity(x)` and it compiles fine.

Why doesn't the Copy chip actually write to the clipboard?

The chip only records the tapped code in `_copied` so the UI can show its green Copied state — clipboard access is left to you as a one-liner. In `_copyChip`'s `onTap`, add `Clipboard.setData(ClipboardData(text: code))` and import `package:flutter/services.dart`. It was kept out so the screen stays free of side effects in previews.

The notches on the ticket edges look wrong on my background — why?

The 'punched' notches are not transparent cut-outs; they are circles filled with `#F2F2F2`, the same grey as the page background, drawn over the card's edges. If you change the Scaffold background, update the notch paint colour in `_TicketPainter` (and the `_surface` token) to match, or the circles will show as visible dots.

Related screens