E-commerce21 views

How to Build a Points Redemption Screen in Flutter (Full Code + Preview)

A rewards catalogue frustrates people when they tap a voucher they cannot afford and only find out a step later. This tutorial builds StyleCart's redeem-points screen in Flutter around one comparison: balance against cost. Seven rewards render as coupon tickets whose notches are cut out of the path itself. Filter chips narrow them by category, affordable cards get a coral Redeem button, the 4,500-point voucher shows Locked with the shortfall, and picking a reward raises a Confirm bar that returns its title through a callback.

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

What you'll build

  • ✓A reward catalogue held as a const list, with chip filtering derived fresh on every build
  • ✓Per-card affordability gating: a coral Redeem button when `_balance >= cost`, a Locked pill reading '2,020 more' when not
  • ✓A tap-again-to-deselect selection that thickens the ticket border to 1.6px coral and flips the button to 'Selected'
  • ✓Coupon-ticket cards painted with `Path.combine(PathOperation.difference)`, so the side notches are true cut-outs
  • ✓A pinned 'Confirm — <reward>' bar that appears only when something is selected and hands the choice to `onRedeem`

Step-by-step build

1

Create the file

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

The contract, the balance, and a catalogue built for filtering

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

/// StyleCart — Redeem points.
///
/// The reward catalog: a sticky balance header, category filter chips, and a
/// list of redeemable rewards (vouchers / free shipping / discounts / perks)
/// as painted-coupon cards with point cost and an enabled/disabled redeem
/// button gated on the current balance. A pinned bar confirms the selection.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (coupon ticket edge). Exposes callbacks only.
class EcomRewardsRedeemScreen extends StatefulWidget {
  const EcomRewardsRedeemScreen({super.key, this.onBack, this.onRedeem});

  final VoidCallback? onBack;
  final ValueChanged<String>? onRedeem;

  @override
  State<EcomRewardsRedeemScreen> createState() =>
      _EcomRewardsRedeemScreenState();
}

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

  static const int _balance = 2480;

  static const List<String> _filters = <String>[
    'All',
    'Vouchers',
    'Shipping',
    'Discounts',
    'Perks',
  ];

  static const List<_Reward> _rewards = <_Reward>[
    _Reward('Free standard shipping', 'On your next order', 200, 'Shipping',
        Icons.local_shipping_outlined, Color(0xFF1A6DB5)),
    _Reward('\$10 off voucher', 'Min spend \$50', 1000, 'Vouchers',
        Icons.sell_outlined, Color(0xFFFF385C)),
    _Reward('15% off everything', 'One-time use, any category', 1500,
        'Discounts', Icons.percent_rounded, Color(0xFF8E1537)),
    _Reward('\$25 off voucher', 'Min spend \$120', 2400, 'Vouchers',
        Icons.redeem_outlined, Color(0xFF2E9E5B)),
    _Reward('Early sale access', '24h before everyone else', 800, 'Perks',
        Icons.flash_on_rounded, Color(0xFFF5A623)),
    _Reward('Free express shipping', 'Next-day delivery', 600, 'Shipping',
        Icons.bolt_rounded, Color(0xFF1F6F8B)),
    _Reward('\$50 off voucher', 'Min spend \$250', 4500, 'Vouchers',
        Icons.card_giftcard_outlined, Color(0xFF6A4C93)),
  ];

  int _filter = 0;
  String? _selected;

This is a `StatefulWidget` with just two outward hooks: `onBack` and `onRedeem`, typed `ValueChanged<String>` rather than `VoidCallback` because the host needs to know which reward was chosen. Everything the gating logic depends on is the single constant `_balance = 2480`. The seven `_Reward` entries each carry a title, condition line, integer `cost`, a `category` string, an icon and a per-reward tint (shipping in blues `#1A6DB5`/`#1F6F8B`, the $25 voucher in green `#2E9E5B`, early access in amber `#F5A623`). The `category` strings deliberately match the labels in `_filters` word for word, since that is what the filter compares against. Costs are spread so the demo exercises every state: six land at or below 2,480 (the $25 voucher at 2,400 only just), one at 4,500 does not. Mutable state is two fields — `_filter` as a chip index and `_selected` as a nullable title.

build(): derive the visible list, then decide whether the bar exists

ecom_rewards_redeem_screen.dart
  @override
  Widget build(BuildContext context) {
    final List<_Reward> visible = _filter == 0
        ? _rewards
        : _rewards
            .where((_Reward r) => r.category == _filters[_filter])
            .toList();

    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              _balanceCard(),
              _filterRow(),
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.fromLTRB(20, 6, 20, 24),
                  itemCount: visible.length,
                  itemBuilder: (BuildContext _, int i) =>
                      _rewardCard(visible[i]),
                ),
              ),
              if (_selected != null) _bottomBar(),
            ],
          ),
        ),
      ),
    );
  }

The first job of `build` is computing `visible`: index 0 ('All') short-circuits to the full `_rewards` list, otherwise a `where` keeps rewards whose `category` equals `_filters[_filter]`. Because it is recomputed on every build rather than cached in state, there is no second list to keep in sync when a chip changes. The layout is a `Column` where the header, balance card and chip row are fixed and only the `Expanded` `ListView.builder` scrolls, so your balance stays visible while you compare costs further down. The last child is `if (_selected != null) _bottomBar()` — a collection-if, not an `Offstage` or opacity toggle, so the bar does not exist in the tree until a reward is chosen, and when it appears the `Expanded` list simply gives up 88px. The whole screen sits inside a light `Theme` so host styling cannot leak in.

A bare header and the dark balance strip

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

  Widget _balanceCard() {
    return Container(
      margin: const EdgeInsets.fromLTRB(20, 4, 20, 12),
      padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(16),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[Color(0xFF2A2A2E), Color(0xFF111114)],
        ),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.stars_rounded, size: 28, color: Color(0xFFE9C46A)),
          const SizedBox(width: 12),
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: const <Widget>[
              Text(
                'Your balance',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  color: Colors.white60,
                ),
              ),
              SizedBox(height: 2),
              Text(
                '2,480 points',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 19,
                  fontWeight: FontWeight.w800,
                  color: Colors.white,
                ),
              ),
            ],
          ),
          const Spacer(),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
            decoration: BoxDecoration(
              color: Colors.white.withValues(alpha: 0.12),
              borderRadius: BorderRadius.circular(9),
            ),
            child: const Text(
              'Gold',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12,
                fontWeight: FontWeight.w800,
                color: Color(0xFFE9C46A),
              ),
            ),
          ),
        ],
      ),
    );
  }

The header is a back `IconButton` and a 20px `w800` 'Redeem points' title — no trailing actions, because this screen has one job. Below it, `_balanceCard` is a compact horizontal strip rather than a hero: a 16px-radius `LinearGradient` from `#2A2A2E` to `#111114`, a 28px `stars_rounded` icon in muted gold `#E9C46A`, 'Your balance' in `Colors.white60` over '2,480 points' at 19px `w800`, and a `Spacer` pushing a 'Gold' tier tag to the right on `Colors.white.withValues(alpha: 0.12)`. Keeping it short matters here since every pixel it takes is one less reward on screen. One honest caveat: '2,480 points' is a literal string, not `_formatPts(_balance)`. Change `_balance` and the gating updates while this label does not, so swap it for the formatted value when you wire real data.

Category chips that drive the filter

ecom_rewards_redeem_screen.dart
  Widget _filterRow() {
    return SizedBox(
      height: 38,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20),
        itemCount: _filters.length,
        separatorBuilder: (BuildContext _, int i) => const SizedBox(width: 8),
        itemBuilder: (BuildContext _, int i) {
          final bool active = _filter == i;
          return GestureDetector(
            onTap: () => setState(() => _filter = i),
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: active ? _ink : _surface,
                borderRadius: BorderRadius.circular(9999),
              ),
              child: Text(
                _filters[i],
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  fontWeight: FontWeight.w700,
                  color: active ? _canvas : _muted,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

The chips live in a horizontal `ListView.separated` inside a fixed 38px `SizedBox`, so five labels can overflow narrow phones and scroll sideways instead of wrapping onto a second line. `separatorBuilder` supplies the 8px gaps, which keeps spacing between chips only, never after the last. Each chip is a `GestureDetector` whose `onTap` runs `setState(() => _filter = i)` — that is all it takes, because `build` re-derives `visible` from the new index. The active chip inverts to an `_ink` fill with white text, inactive ones sit on `_surface` grey with `_muted` labels, and `BorderRadius.circular(9999)` turns each into a full pill whatever its width. Note what the tap does not do: it leaves `_selected` alone, so a chosen reward stays chosen while you browse other categories.

The reward card: two booleans decide its look

ecom_rewards_redeem_screen.dart
  Widget _rewardCard(_Reward r) {
    final bool affordable = _balance >= r.cost;
    final bool selected = _selected == r.title;
    return Container(
      margin: const EdgeInsets.only(top: 12),
      child: CustomPaint(
        painter: _CouponPainter(
          selected ? _brand : _hairline,
          selected ? 1.6 : 1.0,
        ),
        child: Padding(
          padding: const EdgeInsets.all(14),
          child: Row(
            children: <Widget>[
              Container(
                width: 52,
                height: 52,
                decoration: BoxDecoration(
                  color: r.tint.withValues(alpha: 0.12),
                  borderRadius: BorderRadius.circular(13),
                ),
                child: Icon(r.icon, size: 24, color: r.tint),
              ),
              const SizedBox(width: 14),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      r.title,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w800,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 2),
                    Text(
                      r.subtitle,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                    const SizedBox(height: 6),
                    Row(
                      children: <Widget>[
                        const Icon(Icons.stars_rounded,
                            size: 15, color: _brand),
                        const SizedBox(width: 3),
                        Text(
                          '${_formatPts(r.cost)} pts',
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 12.5,
                            fontWeight: FontWeight.w800,
                            color: _ink,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              const SizedBox(width: 10),
              _redeemButton(r, affordable, selected),
            ],
          ),
        ),
      ),
    );
  }

`_rewardCard` opens by computing `affordable = _balance >= r.cost` and `selected = _selected == r.title`, and those two flags drive everything that varies. The card has no `BoxDecoration`; its background and outline come entirely from `CustomPaint` with `_CouponPainter`, which receives `_brand` at stroke 1.6 when selected and `_hairline` at 1.0 otherwise — selection reads as a heavier coral outline, not a fill change. Inside, a 52×52 tile tints itself from the reward (`r.tint.withValues(alpha: 0.12)` behind the icon in full `r.tint`), so categories are distinguishable at a glance without reading. The text column runs title at 14.5px `w800`, condition ('Min spend $120') in `_muted`, then a cost row: a 15px coral `stars_rounded` glyph plus `_formatPts(r.cost)` — the same star as the balance strip, acting as the points currency symbol.

Affordability gating: Locked pill versus Redeem toggle

ecom_rewards_redeem_screen.dart
  Widget _redeemButton(_Reward r, bool affordable, bool selected) {
    if (!affordable) {
      return Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
            decoration: BoxDecoration(
              color: _surface,
              borderRadius: BorderRadius.circular(9999),
            ),
            child: const Text(
              'Locked',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w800,
                color: _faint,
              ),
            ),
          ),
          const SizedBox(height: 4),
          Text(
            '${_formatPts(r.cost - _balance)} more',
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w600,
              color: _faint,
            ),
          ),
        ],
      );
    }
    return GestureDetector(
      onTap: () => setState(() => _selected = selected ? null : r.title),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
        decoration: BoxDecoration(
          color: selected ? _ink : _brand,
          borderRadius: BorderRadius.circular(9999),
        ),
        child: Text(
          selected ? 'Selected' : 'Redeem',
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 12.5,
            fontWeight: FontWeight.w800,
            color: _canvas,
          ),
        ),
      ),
    );
  }

`_redeemButton` returns entirely different widgets for the two cases rather than one button with a disabled flag. When `!affordable` it builds a non-interactive column: a grey `_surface` pill reading 'Locked' in `_faint` `#C1C1C1`, and beneath it `'${_formatPts(r.cost - _balance)} more'` at 10.5px — for the $50 voucher that is '2,020 more'. There is no `GestureDetector` at all, so a locked reward cannot be selected by accident, and the shortfall turns a dead end into a target. Affordable rewards get a pill wrapped in a `GestureDetector` whose tap runs `_selected = selected ? null : r.title`: tapping a fresh reward selects it (replacing any previous one, since only one title is stored), tapping the selected one clears it. The pill flips from coral 'Redeem' to `_ink` 'Selected', a darker, settled colour signalling the action has moved to the bar below.

The confirm bar and hand-rolled thousands separators

ecom_rewards_redeem_screen.dart
  Widget _bottomBar() {
    return Container(
      height: 88,
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
      child: GestureDetector(
        onTap: () => widget.onRedeem?.call(_selected!),
        child: Container(
          height: 56,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: _brand,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: Text(
            'Confirm — $_selected',
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 15.5,
              fontWeight: FontWeight.w800,
              color: Colors.white,
            ),
          ),
        ),
      ),
    );
  }

  static String _formatPts(int n) {
    final String s = n.toString();
    final StringBuffer out = StringBuffer();
    for (int i = 0; i < s.length; i++) {
      if (i > 0 && (s.length - i) % 3 == 0) out.write(',');
      out.write(s[i]);
    }
    return out.toString();
  }
}

Redeeming is two taps on purpose: the card button only selects, and the irreversible spend happens in `_bottomBar`. That bar is an 88px white container with a top `_hairline` border holding a 56px coral pill that reads `'Confirm — $_selected'`, so the reward is named on the button that spends points. `maxLines: 1` with `TextOverflow.ellipsis` keeps long titles like 'Free standard shipping' from breaking the pill. Its tap calls `widget.onRedeem?.call(_selected!)` — the bang is safe because the bar only exists when `_selected` is non-null. The screen does not deduct points or clear the selection afterwards; that is your callback's job. `_formatPts` inserts a comma whenever the remaining digit count is divisible by three, giving '1,000' and '4,500' without pulling in `intl` for one formatting rule.

The reward model and a notched ticket cut with path maths

ecom_rewards_redeem_screen.dart
class _Reward {
  const _Reward(this.title, this.subtitle, this.cost, this.category, this.icon,
      this.tint);
  final String title;
  final String subtitle;
  final int cost;
  final String category;
  final IconData icon;
  final Color tint;
}

/// A coupon-ticket card: rounded body with two notches punched into the left
/// and right edges and a coloured 1px border.
class _CouponPainter extends CustomPainter {
  _CouponPainter(this.border, this.strokeScale);
  final Color border;
  final double strokeScale;

  @override
  void paint(Canvas canvas, Size size) {
    const double radius = 16;
    const double notch = 7;
    final double midY = size.height / 2;

    final Path path = Path()
      ..addRRect(RRect.fromRectAndRadius(
        Offset.zero & size,
        const Radius.circular(radius),
      ));
    // Punch side notches by subtracting two circles.
    final Path notches = Path()
      ..addOval(Rect.fromCircle(center: Offset(0, midY), radius: notch))
      ..addOval(Rect.fromCircle(center: Offset(size.width, midY), radius: notch));
    final Path body =
        Path.combine(PathOperation.difference, path, notches);

    canvas.drawPath(body, Paint()..color = const Color(0xFFFFFFFF));
    canvas.drawPath(
      body,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = strokeScale
        ..color = border,
    );
  }

  @override
  bool shouldRepaint(_CouponPainter old) =>
      old.border != border || old.strokeScale != strokeScale;
}

`_Reward` is a six-field const class with a positional constructor, which keeps each catalogue entry to two lines. `_CouponPainter` builds the ticket by subtraction: it adds a 16px-radius `RRect` covering the whole size, then a second path of two 7px-radius ovals centred exactly on the left and right edges at `size.height / 2`, and calls `Path.combine(PathOperation.difference, path, notches)`. The result is a shape with real semicircular bites removed, so the notches show whatever is behind the card rather than relying on a circle painted in the page colour. Because the white fill and the stroke both draw that same `body` path, the border follows the notch curve inward. Centring on `midY` means the notches stay level with the card's middle even if a title wraps. `shouldRepaint` compares `border` and `strokeScale`, so only the cards whose selection changed repaint.

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 — Redeem points.
///
/// The reward catalog: a sticky balance header, category filter chips, and a
/// list of redeemable rewards (vouchers / free shipping / discounts / perks)
/// as painted-coupon cards with point cost and an enabled/disabled redeem
/// button gated on the current balance. A pinned bar confirms the selection.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (coupon ticket edge). Exposes callbacks only.
class EcomRewardsRedeemScreen extends StatefulWidget {
  const EcomRewardsRedeemScreen({super.key, this.onBack, this.onRedeem});

  final VoidCallback? onBack;
  final ValueChanged<String>? onRedeem;

  @override
  State<EcomRewardsRedeemScreen> createState() =>
      _EcomRewardsRedeemScreenState();
}

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

  static const int _balance = 2480;

  static const List<String> _filters = <String>[
    'All',
    'Vouchers',
    'Shipping',
    'Discounts',
    'Perks',
  ];

  static const List<_Reward> _rewards = <_Reward>[
    _Reward('Free standard shipping', 'On your next order', 200, 'Shipping',
        Icons.local_shipping_outlined, Color(0xFF1A6DB5)),
    _Reward('\$10 off voucher', 'Min spend \$50', 1000, 'Vouchers',
        Icons.sell_outlined, Color(0xFFFF385C)),
    _Reward('15% off everything', 'One-time use, any category', 1500,
        'Discounts', Icons.percent_rounded, Color(0xFF8E1537)),
    _Reward('\$25 off voucher', 'Min spend \$120', 2400, 'Vouchers',
        Icons.redeem_outlined, Color(0xFF2E9E5B)),
    _Reward('Early sale access', '24h before everyone else', 800, 'Perks',
        Icons.flash_on_rounded, Color(0xFFF5A623)),
    _Reward('Free express shipping', 'Next-day delivery', 600, 'Shipping',
        Icons.bolt_rounded, Color(0xFF1F6F8B)),
    _Reward('\$50 off voucher', 'Min spend \$250', 4500, 'Vouchers',
        Icons.card_giftcard_outlined, Color(0xFF6A4C93)),
  ];

  int _filter = 0;
  String? _selected;

  @override
  Widget build(BuildContext context) {
    final List<_Reward> visible = _filter == 0
        ? _rewards
        : _rewards
            .where((_Reward r) => r.category == _filters[_filter])
            .toList();

    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              _balanceCard(),
              _filterRow(),
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.fromLTRB(20, 6, 20, 24),
                  itemCount: visible.length,
                  itemBuilder: (BuildContext _, int i) =>
                      _rewardCard(visible[i]),
                ),
              ),
              if (_selected != null) _bottomBar(),
            ],
          ),
        ),
      ),
    );
  }

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

  Widget _balanceCard() {
    return Container(
      margin: const EdgeInsets.fromLTRB(20, 4, 20, 12),
      padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(16),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[Color(0xFF2A2A2E), Color(0xFF111114)],
        ),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.stars_rounded, size: 28, color: Color(0xFFE9C46A)),
          const SizedBox(width: 12),
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: const <Widget>[
              Text(
                'Your balance',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  color: Colors.white60,
                ),
              ),
              SizedBox(height: 2),
              Text(
                '2,480 points',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 19,
                  fontWeight: FontWeight.w800,
                  color: Colors.white,
                ),
              ),
            ],
          ),
          const Spacer(),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
            decoration: BoxDecoration(
              color: Colors.white.withValues(alpha: 0.12),
              borderRadius: BorderRadius.circular(9),
            ),
            child: const Text(
              'Gold',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12,
                fontWeight: FontWeight.w800,
                color: Color(0xFFE9C46A),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _filterRow() {
    return SizedBox(
      height: 38,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20),
        itemCount: _filters.length,
        separatorBuilder: (BuildContext _, int i) => const SizedBox(width: 8),
        itemBuilder: (BuildContext _, int i) {
          final bool active = _filter == i;
          return GestureDetector(
            onTap: () => setState(() => _filter = i),
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: active ? _ink : _surface,
                borderRadius: BorderRadius.circular(9999),
              ),
              child: Text(
                _filters[i],
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13,
                  fontWeight: FontWeight.w700,
                  color: active ? _canvas : _muted,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  Widget _rewardCard(_Reward r) {
    final bool affordable = _balance >= r.cost;
    final bool selected = _selected == r.title;
    return Container(
      margin: const EdgeInsets.only(top: 12),
      child: CustomPaint(
        painter: _CouponPainter(
          selected ? _brand : _hairline,
          selected ? 1.6 : 1.0,
        ),
        child: Padding(
          padding: const EdgeInsets.all(14),
          child: Row(
            children: <Widget>[
              Container(
                width: 52,
                height: 52,
                decoration: BoxDecoration(
                  color: r.tint.withValues(alpha: 0.12),
                  borderRadius: BorderRadius.circular(13),
                ),
                child: Icon(r.icon, size: 24, color: r.tint),
              ),
              const SizedBox(width: 14),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      r.title,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w800,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 2),
                    Text(
                      r.subtitle,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                    const SizedBox(height: 6),
                    Row(
                      children: <Widget>[
                        const Icon(Icons.stars_rounded,
                            size: 15, color: _brand),
                        const SizedBox(width: 3),
                        Text(
                          '${_formatPts(r.cost)} pts',
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 12.5,
                            fontWeight: FontWeight.w800,
                            color: _ink,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              const SizedBox(width: 10),
              _redeemButton(r, affordable, selected),
            ],
          ),
        ),
      ),
    );
  }

  Widget _redeemButton(_Reward r, bool affordable, bool selected) {
    if (!affordable) {
      return Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
            decoration: BoxDecoration(
              color: _surface,
              borderRadius: BorderRadius.circular(9999),
            ),
            child: const Text(
              'Locked',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w800,
                color: _faint,
              ),
            ),
          ),
          const SizedBox(height: 4),
          Text(
            '${_formatPts(r.cost - _balance)} more',
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 10.5,
              fontWeight: FontWeight.w600,
              color: _faint,
            ),
          ),
        ],
      );
    }
    return GestureDetector(
      onTap: () => setState(() => _selected = selected ? null : r.title),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
        decoration: BoxDecoration(
          color: selected ? _ink : _brand,
          borderRadius: BorderRadius.circular(9999),
        ),
        child: Text(
          selected ? 'Selected' : 'Redeem',
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 12.5,
            fontWeight: FontWeight.w800,
            color: _canvas,
          ),
        ),
      ),
    );
  }

  Widget _bottomBar() {
    return Container(
      height: 88,
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
      child: GestureDetector(
        onTap: () => widget.onRedeem?.call(_selected!),
        child: Container(
          height: 56,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: _brand,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: Text(
            'Confirm — $_selected',
            maxLines: 1,
            overflow: TextOverflow.ellipsis,
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 15.5,
              fontWeight: FontWeight.w800,
              color: Colors.white,
            ),
          ),
        ),
      ),
    );
  }

  static String _formatPts(int n) {
    final String s = n.toString();
    final StringBuffer out = StringBuffer();
    for (int i = 0; i < s.length; i++) {
      if (i > 0 && (s.length - i) % 3 == 0) out.write(',');
      out.write(s[i]);
    }
    return out.toString();
  }
}

class _Reward {
  const _Reward(this.title, this.subtitle, this.cost, this.category, this.icon,
      this.tint);
  final String title;
  final String subtitle;
  final int cost;
  final String category;
  final IconData icon;
  final Color tint;
}

/// A coupon-ticket card: rounded body with two notches punched into the left
/// and right edges and a coloured 1px border.
class _CouponPainter extends CustomPainter {
  _CouponPainter(this.border, this.strokeScale);
  final Color border;
  final double strokeScale;

  @override
  void paint(Canvas canvas, Size size) {
    const double radius = 16;
    const double notch = 7;
    final double midY = size.height / 2;

    final Path path = Path()
      ..addRRect(RRect.fromRectAndRadius(
        Offset.zero & size,
        const Radius.circular(radius),
      ));
    // Punch side notches by subtracting two circles.
    final Path notches = Path()
      ..addOval(Rect.fromCircle(center: Offset(0, midY), radius: notch))
      ..addOval(Rect.fromCircle(center: Offset(size.width, midY), radius: notch));
    final Path body =
        Path.combine(PathOperation.difference, path, notches);

    canvas.drawPath(body, Paint()..color = const Color(0xFFFFFFFF));
    canvas.drawPath(
      body,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = strokeScale
        ..color = border,
    );
  }

  @override
  bool shouldRepaint(_CouponPainter old) =>
      old.border != border || old.strokeScale != strokeScale;
}

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-rewards-redeem

2. AI agent (MCP)

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

FAQ

Can I ship this redemption screen in a paid app?

Yes. It is free for personal and commercial projects alike — copy it from this page, add it with the CLI, or fetch it over MCP. There is no licence key to register and no attribution line to include.

Which packages or fonts does it rely on?

None beyond Flutter itself — the only import is `package:flutter/material.dart`; even the comma grouping is done by `_formatPts` instead of `intl`, and the ticket shape is a `CustomPainter`. Text uses the bundled Manrope family via `fontFamily: 'Manrope'`, which the install adds to your project's assets and pubspec.

What Flutter version is required?

Flutter 3.22 or later, because the Gold tag and the reward icon tiles use `Color.withValues(alpha: …)`. For an older SDK replace those calls with `withOpacity(0.12)`, and rewrite `super.key` in the constructor as `{Key? key, this.onBack, this.onRedeem} : super(key: key)`. `Path.combine` is long-standing and needs no change.

How do I connect a real balance and actually spend the points?

Turn `_balance` into a widget parameter (or read it from your state layer) and replace the literal '2,480 points' in `_balanceCard` with `_formatPts(_balance)` — otherwise the label drifts from the gating. Give `_Reward` an id and pass that to `onRedeem` instead of the title, since two rewards could share a name. When your API confirms, refresh the balance and `setState(() => _selected = null)` to dismiss the bar.

Why does the Confirm bar stay visible after I switch to another category?

Because the chip tap only updates `_filter`; `_selected` survives, so you can pick shipping, browse Vouchers, and still confirm shipping. That is handy for comparing, but the selected card may be out of view. If you prefer the bar to follow the filter, set `_selected = null` inside the chip's `setState`, or only clear it when the selected reward is not in `visible`.

Related screens