E-commerce54 views

How to Build a Payment Method Screen in Flutter (Full Code + Preview)

Checkout step three: two saved cards and four alternative payment rails, all behaving as one radio group even though they're built by different widgets. The trick is a single `_sel` integer over a flat index space — cards take indices 0..n-1 and rails continue from there — so selecting any option automatically deselects every other. You'll also get card scheme marks painted on canvas rather than downloaded, which keeps a payment screen free of network calls and licensed logo assets.

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

Watch the Flutter UI walkthrough

A short screen recording of Payment Method running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.

Can't see the video? Watch it on YouTube.

What you'll build

  • One radio group spanning two different row types, driven by a single `_sel` index
  • Visa and Mastercard scheme marks drawn with `CustomPainter` — no image assets, no network requests
  • Selection feedback via `AnimatedContainer`, with the border shifting colour and thickness over 150ms
  • A radio dot built from nothing but a circular `Border` whose width grows to fill the centre
  • A 'STEP 3 OF 4' eyebrow, a secure-badge header, an add-card tile, and a pinned Review order bar

Step-by-step build

1

Create the file

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

Two data sets, one index space

ecom_checkout_payment_methods_screen.dart
class EcomCheckoutPaymentMethodsScreen extends StatefulWidget {
  const EcomCheckoutPaymentMethodsScreen({
    super.key,
    this.onBack,
    this.onAddCard,
    this.onContinue,
  });

  final VoidCallback? onBack;
  final VoidCallback? onAddCard;
  final VoidCallback? onContinue;

  @override
  State<EcomCheckoutPaymentMethodsScreen> createState() =>
      _EcomCheckoutPaymentMethodsScreenState();
}

class _EcomCheckoutPaymentMethodsScreenState
    extends State<EcomCheckoutPaymentMethodsScreen> {
  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 List<_Card> _cards = <_Card>[
    _Card(_Scheme.visa, 'Visa', '•••• 4291', 'Expires 08/27', true),
    _Card(_Scheme.mastercard, 'Mastercard', '•••• 7755', 'Expires 11/26', false),
  ];

  static const List<_Rail> _rails = <_Rail>[
    _Rail(Icons.account_balance_wallet_rounded, 'PayPal', 'mara@quinn.co'),
    _Rail(Icons.phone_iphone_rounded, 'Apple Pay', 'Touch to pay'),
    _Rail(Icons.account_balance_rounded, 'Bank transfer', 'Net banking'),
    _Rail(Icons.payments_outlined, 'Cash on delivery', 'Pay when it arrives'),
  ];

  // 0..n-1 cards, then rails.
  int _sel = 0;

`_cards` holds two `_Card` records — scheme enum, brand, masked last four, expiry, and an `isDefault` flag — while `_rails` holds four `_Rail` records of icon, title, and subtitle. They're different shapes and render through different builders, yet the comment above `_sel` states the contract that unifies them: `0..n-1 cards, then rails`. One integer, one shared numbering. That's why there is no way for a card and a rail to both appear selected, which is exactly the bug you get if each section keeps its own selection variable.

Laying out both sections against that index space

ecom_checkout_payment_methods_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
                  children: <Widget>[
                    const Text(
                      'STEP 3 OF 4',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 11,
                        fontWeight: FontWeight.w700,
                        letterSpacing: 0.8,
                        color: _brand,
                      ),
                    ),
                    const SizedBox(height: 4),
                    const Text(
                      'Payment method',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 22,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.5,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 18),
                    _sectionLabel('Saved cards'),
                    for (int i = 0; i < _cards.length; i++) ...<Widget>[
                      _cardRow(_cards[i], i),
                      const SizedBox(height: 12),
                    ],
                    _addTile(),
                    const SizedBox(height: 22),
                    _sectionLabel('Other ways to pay'),
                    for (int i = 0; i < _rails.length; i++) ...<Widget>[
                      _railRow(_rails[i], _cards.length + i),
                      const SizedBox(height: 12),
                    ],
                  ],
                ),
              ),
              _continueBar(),
            ],
          ),
        ),
      ),
    );
  }

The two collection-for loops are where the index arithmetic happens. Cards pass their own `i`, and rails pass `_cards.length + i` — that single offset is the whole implementation of the flat index space. Above them, a 'STEP 3 OF 4' eyebrow in brand red at 11px with `letterSpacing: 0.8` gives the screen its checkout context; wide tracking on small uppercase text is what makes it read as a label rather than body copy. The header adds a lock icon and 'Secure' at the trailing edge — a small trust cue that costs two widgets and belongs on any payment screen.

The saved-card row

ecom_checkout_payment_methods_screen.dart
  Widget _cardRow(_Card c, int i) {
    final bool active = _sel == i;
    return GestureDetector(
      onTap: () => setState(() => _sel = i),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(
            color: active ? _brand : _hairline,
            width: active ? 1.6 : 1,
          ),
        ),
        child: Row(
          children: <Widget>[
            SizedBox(
              width: 46,
              height: 30,
              child: CustomPaint(painter: _SchemePainter(c.scheme)),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Row(
                    children: <Widget>[
                      Text(
                        '${c.brand}  ${c.last4}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 14.5,
                          fontWeight: FontWeight.w800,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(width: 8),
                      if (c.isDefault) _defaultTag(),
                    ],
                  ),
                  const SizedBox(height: 2),
                  Text(
                    c.expiry,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            _radio(active),
          ],
        ),
      ),
    );
  }

`active` is computed as `_sel == i` and drives an `AnimatedContainer` that tweens its border from 1px `_hairline` to 1.6px brand-red over 150ms — the row grows a coloured outline rather than changing fill, which keeps the card list calm. The scheme mark is a 46×30 `CustomPaint` at the leading edge, matching the aspect ratio of a real card logo block. The title interpolates `'${c.brand} ${c.last4}'` with a double space, and the 'Default' tag is spread in with `if (c.isDefault)` so non-default cards have no leftover gap. The trailing `_radio(active)` closes the row.

The alternative rails row

ecom_checkout_payment_methods_screen.dart
  Widget _railRow(_Rail r, int i) {
    final bool active = _sel == i;
    return GestureDetector(
      onTap: () => setState(() => _sel = i),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(
            color: active ? _brand : _hairline,
            width: active ? 1.6 : 1,
          ),
        ),
        child: Row(
          children: <Widget>[
            Container(
              width: 46,
              height: 30,
              decoration: BoxDecoration(
                color: _surface,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Icon(r.icon, size: 19, color: _ink),
            ),
            const SizedBox(width: 12),
            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.sub,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            _radio(active),
          ],
        ),
      ),
    );
  }

  Widget _defaultTag() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
      decoration: BoxDecoration(
        color: _brand.withValues(alpha: 0.10),
        borderRadius: BorderRadius.circular(6),
      ),
      child: const Text(
        'Default',
        style: TextStyle(
          fontFamily: _font,
          fontSize: 10.5,
          fontWeight: FontWeight.w700,
          color: _brand,
        ),
      ),
    );
  }

  Widget _addTile() {
    return GestureDetector(
      onTap: widget.onAddCard,
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 15),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: _hairline),
        ),
        child: const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Icon(Icons.add_card_rounded, size: 20, color: _brand),
            SizedBox(width: 8),
            Text(
              'Add a new card',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 14.5,
                fontWeight: FontWeight.w700,
                color: _brand,
              ),
            ),
          ],
        ),
      ),
    );
  }

`_railRow` is deliberately near-identical to `_cardRow` — same padding, same 16px radius, same animated border, same trailing radio — so the two sections read as one list despite being separate builders. The only structural difference is the leading slot: instead of a painted scheme mark, a 46×30 grey `_surface` tile holds a Material icon, keeping the leading column exactly the same width so every title lines up down the screen. `_defaultTag` uses the tint-on-brand recipe (`_brand.withValues(alpha: 0.10)` behind full-strength brand text), and `_addTile` is intentionally *not* part of the radio group — it's an outlined action in brand red that fires `onAddCard` and carries no radio.

A radio button made of one border

ecom_checkout_payment_methods_screen.dart
  Widget _radio(bool active) {
    return Container(
      width: 22,
      height: 22,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(
          color: active ? _brand : _faint,
          width: active ? 6.5 : 2,
        ),
      ),
    );
  }

  Widget _continueBar() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: SizedBox(
          height: 88,
          child: Padding(
            padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
            child: SizedBox(
              height: 56,
              child: FilledButton(
                onPressed: widget.onContinue,
                style: FilledButton.styleFrom(
                  backgroundColor: _brand,
                  foregroundColor: _canvas,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(16),
                  ),
                ),
                child: const Text(
                  'Review order',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 16,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

`_radio` is worth stealing: a 22×22 circular `Container` with no child and no fill, whose entire appearance comes from `Border.all`. Unselected it's a 2px `_faint` ring; selected it's a 6.5px brand-red ring, and because a border grows inward, that thickness nearly closes the circle and reads as a filled dot inside a ring. One widget, two states, no `Stack`, no nested circles, and no Material `Radio` to restyle. The continue bar below follows the same pinned pattern used across this checkout flow — hairline top border, `SafeArea(top: false)`, and a fixed 88px row holding a 56px CTA.

Painting Visa and Mastercard marks

ecom_checkout_payment_methods_screen.dart
class _SchemePainter extends CustomPainter {
  _SchemePainter(this.scheme);
  final _Scheme scheme;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect bg = RRect.fromRectAndRadius(
      Offset.zero & size,
      const Radius.circular(6),
    );

    if (scheme == _Scheme.visa) {
      canvas.drawRRect(bg, Paint()..color = const Color(0xFF1A1F71));
      // Stylised wordmark stripe.
      final Paint bar = Paint()..color = const Color(0xFFFFFFFF);
      canvas.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(size.width * 0.18, size.height * 0.40,
              size.width * 0.64, size.height * 0.16),
          const Radius.circular(2),
        ),
        bar,
      );
      canvas.drawRect(
        Rect.fromLTWH(size.width * 0.18, size.height * 0.40,
            size.width * 0.10, size.height * 0.16),
        Paint()..color = const Color(0xFFF7B600),
      );
    } else {
      canvas.drawRRect(bg, Paint()..color = const Color(0xFF1A1A1A));
      final double cy = size.height / 2;
      final double r = size.height * 0.30;
      canvas.drawCircle(Offset(size.width * 0.40, cy), r,
          Paint()..color = const Color(0xFFEB001B));
      canvas.drawCircle(Offset(size.width * 0.60, cy), r,
          Paint()..color = const Color(0xFFF79E1B).withValues(alpha: 0.92));
    }
  }

  @override
  bool shouldRepaint(_SchemePainter oldDelegate) => oldDelegate.scheme != scheme;
}

Both marks start from the same rounded background rect built with `Offset.zero & size`, Dart's shorthand for a `Rect` from an origin and a size. The Visa branch fills #1A1F71 navy and lays a white stripe across the middle at 40% height, then overpaints its left tenth in #F7B600 gold — a stylised wordmark, not a reproduction. The Mastercard branch fills near-black and draws two circles at 40% and 60% of the width with radius `size.height * 0.30`, so they overlap in the centre; the second is #F79E1B at 92% alpha, and that slight transparency is what produces the darker interlock band where the circles meet. Painting these rather than bundling logo files sidesteps trademark asset licensing and keeps the screen fully offline.

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 — Payment Method (checkout step 3).
///
/// Saved cards (with a painted scheme mark and a default tag) plus alternative
/// rails — PayPal, Apple Pay, bank transfer and cash on delivery — as a single
/// radio group, with an "Add card" tile and a pinned continue bar.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Card scheme marks are a
/// CustomPainter (no emoji glyph, no network). Exposes callbacks only.
class EcomCheckoutPaymentMethodsScreen extends StatefulWidget {
  const EcomCheckoutPaymentMethodsScreen({
    super.key,
    this.onBack,
    this.onAddCard,
    this.onContinue,
  });

  final VoidCallback? onBack;
  final VoidCallback? onAddCard;
  final VoidCallback? onContinue;

  @override
  State<EcomCheckoutPaymentMethodsScreen> createState() =>
      _EcomCheckoutPaymentMethodsScreenState();
}

class _EcomCheckoutPaymentMethodsScreenState
    extends State<EcomCheckoutPaymentMethodsScreen> {
  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 List<_Card> _cards = <_Card>[
    _Card(_Scheme.visa, 'Visa', '•••• 4291', 'Expires 08/27', true),
    _Card(_Scheme.mastercard, 'Mastercard', '•••• 7755', 'Expires 11/26', false),
  ];

  static const List<_Rail> _rails = <_Rail>[
    _Rail(Icons.account_balance_wallet_rounded, 'PayPal', 'mara@quinn.co'),
    _Rail(Icons.phone_iphone_rounded, 'Apple Pay', 'Touch to pay'),
    _Rail(Icons.account_balance_rounded, 'Bank transfer', 'Net banking'),
    _Rail(Icons.payments_outlined, 'Cash on delivery', 'Pay when it arrives'),
  ];

  // 0..n-1 cards, then rails.
  int _sel = 0;

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
                  children: <Widget>[
                    const Text(
                      'STEP 3 OF 4',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 11,
                        fontWeight: FontWeight.w700,
                        letterSpacing: 0.8,
                        color: _brand,
                      ),
                    ),
                    const SizedBox(height: 4),
                    const Text(
                      'Payment method',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 22,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.5,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 18),
                    _sectionLabel('Saved cards'),
                    for (int i = 0; i < _cards.length; i++) ...<Widget>[
                      _cardRow(_cards[i], i),
                      const SizedBox(height: 12),
                    ],
                    _addTile(),
                    const SizedBox(height: 22),
                    _sectionLabel('Other ways to pay'),
                    for (int i = 0; i < _rails.length; i++) ...<Widget>[
                      _railRow(_rails[i], _cards.length + i),
                      const SizedBox(height: 12),
                    ],
                  ],
                ),
              ),
              _continueBar(),
            ],
          ),
        ),
      ),
    );
  }

  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_ios_new_rounded,
                size: 20, color: _ink),
          ),
          const Expanded(
            child: Text(
              'Payment',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
          Row(
            children: <Widget>[
              const Icon(Icons.lock_rounded, size: 14, color: _muted),
              const SizedBox(width: 4),
              Text(
                'Secure',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 12,
                  fontWeight: FontWeight.w600,
                  color: _muted,
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _sectionLabel(String s) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 12),
      child: Text(
        s,
        style: const TextStyle(
          fontFamily: _font,
          fontSize: 13,
          fontWeight: FontWeight.w800,
          color: _ink,
        ),
      ),
    );
  }

  Widget _cardRow(_Card c, int i) {
    final bool active = _sel == i;
    return GestureDetector(
      onTap: () => setState(() => _sel = i),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(
            color: active ? _brand : _hairline,
            width: active ? 1.6 : 1,
          ),
        ),
        child: Row(
          children: <Widget>[
            SizedBox(
              width: 46,
              height: 30,
              child: CustomPaint(painter: _SchemePainter(c.scheme)),
            ),
            const SizedBox(width: 12),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Row(
                    children: <Widget>[
                      Text(
                        '${c.brand}  ${c.last4}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 14.5,
                          fontWeight: FontWeight.w800,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(width: 8),
                      if (c.isDefault) _defaultTag(),
                    ],
                  ),
                  const SizedBox(height: 2),
                  Text(
                    c.expiry,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            _radio(active),
          ],
        ),
      ),
    );
  }

  Widget _railRow(_Rail r, int i) {
    final bool active = _sel == i;
    return GestureDetector(
      onTap: () => setState(() => _sel = i),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(
            color: active ? _brand : _hairline,
            width: active ? 1.6 : 1,
          ),
        ),
        child: Row(
          children: <Widget>[
            Container(
              width: 46,
              height: 30,
              decoration: BoxDecoration(
                color: _surface,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Icon(r.icon, size: 19, color: _ink),
            ),
            const SizedBox(width: 12),
            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.sub,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ],
              ),
            ),
            _radio(active),
          ],
        ),
      ),
    );
  }

  Widget _defaultTag() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
      decoration: BoxDecoration(
        color: _brand.withValues(alpha: 0.10),
        borderRadius: BorderRadius.circular(6),
      ),
      child: const Text(
        'Default',
        style: TextStyle(
          fontFamily: _font,
          fontSize: 10.5,
          fontWeight: FontWeight.w700,
          color: _brand,
        ),
      ),
    );
  }

  Widget _addTile() {
    return GestureDetector(
      onTap: widget.onAddCard,
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 15),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: _hairline),
        ),
        child: const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Icon(Icons.add_card_rounded, size: 20, color: _brand),
            SizedBox(width: 8),
            Text(
              'Add a new card',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 14.5,
                fontWeight: FontWeight.w700,
                color: _brand,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _radio(bool active) {
    return Container(
      width: 22,
      height: 22,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(
          color: active ? _brand : _faint,
          width: active ? 6.5 : 2,
        ),
      ),
    );
  }

  Widget _continueBar() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: SizedBox(
          height: 88,
          child: Padding(
            padding: const EdgeInsets.fromLTRB(20, 14, 20, 14),
            child: SizedBox(
              height: 56,
              child: FilledButton(
                onPressed: widget.onContinue,
                style: FilledButton.styleFrom(
                  backgroundColor: _brand,
                  foregroundColor: _canvas,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(16),
                  ),
                ),
                child: const Text(
                  'Review order',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 16,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

enum _Scheme { visa, mastercard }

class _Card {
  const _Card(this.scheme, this.brand, this.last4, this.expiry, this.isDefault);
  final _Scheme scheme;
  final String brand;
  final String last4;
  final String expiry;
  final bool isDefault;
}

class _Rail {
  const _Rail(this.icon, this.title, this.sub);
  final IconData icon;
  final String title;
  final String sub;
}

/// Paints a card scheme mark: Visa as a blue field with a white wordmark bar,
/// Mastercard as the two interlocking circles. No glyphs, no network.
class _SchemePainter extends CustomPainter {
  _SchemePainter(this.scheme);
  final _Scheme scheme;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect bg = RRect.fromRectAndRadius(
      Offset.zero & size,
      const Radius.circular(6),
    );

    if (scheme == _Scheme.visa) {
      canvas.drawRRect(bg, Paint()..color = const Color(0xFF1A1F71));
      // Stylised wordmark stripe.
      final Paint bar = Paint()..color = const Color(0xFFFFFFFF);
      canvas.drawRRect(
        RRect.fromRectAndRadius(
          Rect.fromLTWH(size.width * 0.18, size.height * 0.40,
              size.width * 0.64, size.height * 0.16),
          const Radius.circular(2),
        ),
        bar,
      );
      canvas.drawRect(
        Rect.fromLTWH(size.width * 0.18, size.height * 0.40,
            size.width * 0.10, size.height * 0.16),
        Paint()..color = const Color(0xFFF7B600),
      );
    } else {
      canvas.drawRRect(bg, Paint()..color = const Color(0xFF1A1A1A));
      final double cy = size.height / 2;
      final double r = size.height * 0.30;
      canvas.drawCircle(Offset(size.width * 0.40, cy), r,
          Paint()..color = const Color(0xFFEB001B));
      canvas.drawCircle(Offset(size.width * 0.60, cy), r,
          Paint()..color = const Color(0xFFF79E1B).withValues(alpha: 0.92));
    }
  }

  @override
  bool shouldRepaint(_SchemePainter oldDelegate) => oldDelegate.scheme != scheme;
}

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-checkout-payment-methods

2. AI agent (MCP)

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

FAQ

Is this payment method screen free to use?

Yes. The full Dart source on this page is free for personal and commercial projects. Copy it from the code block, install it with the FlutterKit CLI (flutterkit add ecom-checkout-payment-methods), or add it via an AI agent over MCP.

Does it process payments or need a payment SDK?

No — this is UI only. It has no Stripe, Braintree, or in-app-purchase dependency and makes no network calls. Wire onContinue to your own payment flow and pass the selected method up from _sel. The only asset to register is the bundled Manrope font family.

How do I add another payment option?

Append a _Rail entry to the _rails list — the loop already passes _cards.length + i, so the new option joins the same radio group with no other changes. For another card scheme, add a value to the _Scheme enum and a branch in _SchemePainter.

Are the card logos the official brand marks?

No, and deliberately so. They're simplified shapes painted on canvas — a navy field with a gold-tipped stripe, and two overlapping circles — that read as Visa and Mastercard without shipping trademarked artwork. If your agreement requires the official marks, swap the CustomPaint for an Image.asset of the licensed logo.

Which Flutter version does it target?

It uses Color.withValues(alpha:), FilledButton, and Material 3, so it targets Flutter 3.27+. On an older SDK, replace the two withValues(alpha: x) calls with withOpacity(x) and it compiles back to Flutter 3.10.

Related screens