E-commerce43 views

How to Build a Bank & Card Offers Screen in Flutter (Full Code + Preview)

Card-linked bank offers are where shopping apps quietly win price-sensitive shoppers, but most implementations bury the fine print behind a modal or a separate page. This tutorial builds StyleCart's bank and card offers screen in Flutter: five partner offers, each with a painter-drawn bank monogram, a bold savings headline, an eligible-cards line, a spend-terms pill and an outlined promo-code chip, plus a single-open Terms accordion that expands the conditions in place. Everything lives in one self-contained file, no images, no packages, just Manrope and a CustomPainter.

Bank & Card Offers — E-commerce Flutter UI screen
Live preview — Bank & Card Offers, built in pure Flutter.

What you'll build

  • Painter-drawn 46x46 bank monograms: a tinted rounded square, a 1.2px stroke and centred initials, all from one CustomPainter
  • A const offers dataset where a nullable promo code decides whether a row shows one chip or two
  • Offer cards pairing a savings headline with a surface-grey terms pill and a coral-outlined code chip inside a Wrap
  • A single-open Terms accordion driven by one int of state, with the active row's label and chevron tinted brand coral
  • A ListView.builder that renders an intro note as item zero ahead of the offer list

Step-by-step build

1

Create the file

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

A StatefulWidget shell and an Airbnb-flavoured palette

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

/// StyleCart — Bank & Card Offers.
///
/// Instant-discount offers grouped by partner: each row pairs a painted bank
/// monogram with the headline saving, eligible cards, minimum spend 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
/// (bank monograms). Exposes callbacks only.
class EcomOffersBankScreen extends StatefulWidget {
  const EcomOffersBankScreen({super.key, this.onBack});

  final VoidCallback? onBack;

  @override
  State<EcomOffersBankScreen> createState() => _EcomOffersBankScreenState();
}

class _EcomOffersBankScreenState extends State<EcomOffersBankScreen> {
  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);

`EcomOffersBankScreen` is stateful for exactly one reason: it has to remember which Terms row is expanded. Its only inputs are `super.key` and an optional `onBack` callback, so the screen stays backend-agnostic and drops into any navigator. The tokens are inline `static const` colours: `_canvas` pure white, `_ink` near-black, `_muted` and `_faint` greys for secondary text and idle chevrons, `_brand` coral `0xFFFF385C` reserved for accents, plus `_surface` and `_hairline` greys for pills and borders. Declaring `_font = 'Manrope'` once means every `TextStyle` references the constant instead of repeating a string literal.

Offers as data, including a nullable promo code

ecom_offers_bank_screen.dart
  static const List<_Offer> _offers = <_Offer>[
    _Offer('HD', Color(0xFF004C8F), 'HDFC Bank',
        '10% instant discount', 'Credit & Debit Cards',
        'Up to \$40 · min spend \$120', 'STYLEHDFC',
        'Valid once per card per month on orders above \$120. Max discount \$40.'),
    _Offer('AX', Color(0xFF8E1537), 'Axis Bank',
        'Flat \$25 off', 'Credit Cards',
        'On orders above \$150', 'AXIS25',
        'Applicable on a single transaction. Excludes clearance items.'),
    _Offer('IC', Color(0xFFE07A00), 'ICICI Bank',
        '5% cashback', 'Credit Cards & EMI',
        'Up to \$30 · no min spend', 'ICICICB',
        'Cashback credited within 90 days to the source card.'),
    _Offer('SB', Color(0xFF1A6DB5), 'SBI Card',
        'No-cost EMI', '3 / 6 month tenures',
        'On orders above \$200', null,
        'Interest waived by StyleCart. Processing fee may apply by the bank.'),
    _Offer('AM', Color(0xFF2E7D6B), 'Amex',
        '12% instant discount', 'Membership Rewards Cards',
        'Up to \$60 · min spend \$250', 'AMEX12',
        'Valid for Amex cardholders only. Limited-period offer.'),
  ];

  int _open = -1;

The five offers are a `static const List<_Offer>`, so all copy — bank name, headline, eligible cards, spend terms, code and full conditions — is edited in one block rather than scattered through widgets. Each entry also carries its own identity: two initials and a real-ish brand tint (`0xFF004C8F` for HDFC, `0xFF8E1537` for Axis, and so on) that the painter turns into a monogram. Note the SBI entry passes `null` for its code — no-cost EMI has no coupon to type — and the card builder will later use that to drop the code chip entirely. `int _open = -1` is the whole accordion state: the index of the expanded row, with -1 meaning all closed.

The build method and an off-by-one ListView trick

ecom_offers_bank_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(),
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
                  itemCount: _offers.length + 1,
                  itemBuilder: (BuildContext _, int i) {
                    if (i == 0) return _intro();
                    return _offerCard(_offers[i - 1], i - 1);
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

The screen wraps itself in `Theme(data: ThemeData.light(useMaterial3: true))` so it renders identically inside a dark-themed host app, then lays a fixed `_header()` above an `Expanded` list. The `ListView.builder` sets `itemCount: _offers.length + 1` and treats index 0 as the `_intro()` note, mapping every later index to `_offers[i - 1]` — a common pattern for putting non-list content inside the scrollable without nesting a Column in a scroll view. Padding of `fromLTRB(20, 8, 20, 28)` gives the last card breathing room above the bottom edge.

Header row and the one-line intro

ecom_offers_bank_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(
            'Bank & card offers',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 20,
              fontWeight: FontWeight.w800,
              letterSpacing: -0.3,
              color: _ink,
            ),
          ),
        ],
      ),
    );
  }

  Widget _intro() {
    return const Padding(
      padding: EdgeInsets.only(left: 4, bottom: 14, right: 8),
      child: Text(
        'Pay with an eligible card to apply these savings automatically at '
        'checkout.',
        style: TextStyle(
          fontFamily: _font,
          fontSize: 13,
          fontWeight: FontWeight.w500,
          height: 1.4,
          color: _muted,
        ),
      ),
    );
  }

The header is just a back `IconButton` firing `widget.onBack` beside the 20px `w800` title with `letterSpacing: -0.3` — no actions, because this screen is a leaf you read and leave. Its padding starts at 8 on the left so the icon's own touch-target padding lines the arrow up with the 20px content margin. `_intro()` is a single 13px `_muted` sentence telling shoppers the savings apply automatically at checkout; because it scrolls as list item zero rather than sitting in the header, it disappears once you are browsing the offers themselves.

The offer card: monogram, headline and two kinds of chip

ecom_offers_bank_screen.dart
  Widget _offerCard(_Offer o, int i) {
    final bool open = _open == i;
    return Container(
      margin: const EdgeInsets.only(bottom: 12),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(14),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                CustomPaint(
                  size: const Size(46, 46),
                  painter: _MonogramPainter(o.initials, o.tint),
                ),
                const SizedBox(width: 14),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(
                        o.headline,
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 15.5,
                          fontWeight: FontWeight.w800,
                          letterSpacing: -0.2,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(height: 3),
                      Text(
                        '${o.bank} · ${o.cards}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 12.5,
                          fontWeight: FontWeight.w600,
                          color: _muted,
                        ),
                      ),
                      const SizedBox(height: 8),
                      Wrap(
                        spacing: 8,
                        runSpacing: 8,
                        children: <Widget>[
                          Container(
                            padding: const EdgeInsets.symmetric(
                                horizontal: 9, vertical: 5),
                            decoration: BoxDecoration(
                              color: _surface,
                              borderRadius: BorderRadius.circular(8),
                            ),
                            child: Text(
                              o.terms,
                              style: const TextStyle(
                                fontFamily: _font,
                                fontSize: 11.5,
                                fontWeight: FontWeight.w700,
                                color: _ink,
                              ),
                            ),
                          ),
                          if (o.code != null)
                            Container(
                              padding: const EdgeInsets.symmetric(
                                  horizontal: 9, vertical: 5),
                              decoration: BoxDecoration(
                                borderRadius: BorderRadius.circular(8),
                                border: Border.all(
                                  color: _brand.withValues(alpha: 0.4),
                                ),
                              ),
                              child: Text(
                                o.code!,
                                style: const TextStyle(
                                  fontFamily: _font,
                                  fontSize: 11.5,
                                  fontWeight: FontWeight.w800,
                                  letterSpacing: 0.6,
                                  color: _brand,
                                ),
                              ),
                            ),
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),

Each card is a white `Container` with a 16px radius and a `_hairline` border — separation comes from the border, not elevation, keeping the list flat and calm. The top row starts with `CustomPaint(size: Size(46, 46))` running `_MonogramPainter`, then an `Expanded` column: the savings headline at 15.5px `w800`, and a merged `'${o.bank} · ${o.cards}'` line at 12.5px so bank and eligibility read as one phrase. The chips sit in a `Wrap` with 8px spacing, which lets a long terms string push the code chip onto a second line instead of overflowing. The two chips are deliberately unequal: the spend-terms pill gets a filled `_surface` grey, while the promo code gets a transparent chip outlined in `_brand.withValues(alpha: 0.4)` with coral text and `letterSpacing: 0.6` — codes are the thing you act on, so they get the accent. `if (o.code != null)` is all it takes for the SBI EMI row to render with one chip.

A single-open Terms accordion

ecom_offers_bank_screen.dart
          GestureDetector(
            onTap: () => setState(() => _open = open ? -1 : i),
            behavior: HitTestBehavior.opaque,
            child: Container(
              decoration: const BoxDecoration(
                border: Border(top: BorderSide(color: _hairline)),
              ),
              padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
              child: Row(
                children: <Widget>[
                  Text(
                    'Terms',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w700,
                      color: open ? _brand : _muted,
                    ),
                  ),
                  const Spacer(),
                  Icon(
                    open
                        ? Icons.keyboard_arrow_up_rounded
                        : Icons.keyboard_arrow_down_rounded,
                    size: 20,
                    color: open ? _brand : _faint,
                  ),
                ],
              ),
            ),
          ),
          if (open)
            Padding(
              padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
              child: Align(
                alignment: Alignment.centerLeft,
                child: Text(
                  o.detail,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w500,
                    height: 1.45,
                    color: _muted,
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }

The Terms footer is a `GestureDetector` with `HitTestBehavior.opaque`, so the whole hairline-topped strip is tappable, not just the word. Its tap runs `setState(() => _open = open ? -1 : i)`: tapping the open row closes it, tapping any other row moves the single `_open` index there, which is why opening HDFC's terms automatically collapses Axis — one int enforces the single-open rule with no per-card booleans to keep in sync. The open state recolours both the 'Terms' label and the chevron from `_muted`/`_faint` to `_brand`, and swaps `keyboard_arrow_down_rounded` for the up variant. The detail paragraph itself is an `if (open)` child — 12px `_muted` text with `height: 1.45`, left-aligned via `Align` since the column would otherwise stretch it.

The _Offer model and the monogram painter

ecom_offers_bank_screen.dart
class _Offer {
  const _Offer(this.initials, this.tint, this.bank, this.headline, this.cards,
      this.terms, this.code, this.detail);
  final String initials;
  final Color tint;
  final String bank;
  final String headline;
  final String cards;
  final String terms;
  final String? code;
  final String detail;
}

/// A painted rounded-square bank monogram (tinted fill + white initials).
class _MonogramPainter extends CustomPainter {
  _MonogramPainter(this.initials, this.tint);
  final String initials;
  final Color tint;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect box = RRect.fromRectAndRadius(
      Offset.zero & size,
      const Radius.circular(12),
    );
    canvas.drawRRect(box, Paint()..color = tint.withValues(alpha: 0.12));
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(0, 0, size.width, size.height),
        const Radius.circular(12),
      ),
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.2
        ..color = tint.withValues(alpha: 0.30),
    );

    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: initials,
        style: TextStyle(
          fontFamily: 'Manrope',
          fontSize: size.width * 0.36,
          fontWeight: FontWeight.w800,
          letterSpacing: 0.3,
          color: tint,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(
      canvas,
      Offset((size.width - tp.width) / 2, (size.height - tp.height) / 2),
    );
  }

  @override
  bool shouldRepaint(_MonogramPainter old) =>
      old.initials != initials || old.tint != tint;
}

`_Offer` is a plain const class of eight fields — the only nullable one is `code`, encoding 'this offer has no coupon' in the type. `_MonogramPainter` draws the bank mark in three passes: a rounded square filled with `tint.withValues(alpha: 0.12)`, the same 12px-radius `RRect` stroked at 1.2px in `tint.withValues(alpha: 0.30)`, then the initials laid out with a `TextPainter` at `size.width * 0.36` and centred by subtracting the painter's measured width and height from the box. Because fill, stroke and glyph all derive from one `tint`, adding a bank means adding a list entry, not an asset. `shouldRepaint` compares `initials` and `tint`, so identical monograms never 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 — Bank & Card Offers.
///
/// Instant-discount offers grouped by partner: each row pairs a painted bank
/// monogram with the headline saving, eligible cards, minimum spend 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
/// (bank monograms). Exposes callbacks only.
class EcomOffersBankScreen extends StatefulWidget {
  const EcomOffersBankScreen({super.key, this.onBack});

  final VoidCallback? onBack;

  @override
  State<EcomOffersBankScreen> createState() => _EcomOffersBankScreenState();
}

class _EcomOffersBankScreenState extends State<EcomOffersBankScreen> {
  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<_Offer> _offers = <_Offer>[
    _Offer('HD', Color(0xFF004C8F), 'HDFC Bank',
        '10% instant discount', 'Credit & Debit Cards',
        'Up to \$40 · min spend \$120', 'STYLEHDFC',
        'Valid once per card per month on orders above \$120. Max discount \$40.'),
    _Offer('AX', Color(0xFF8E1537), 'Axis Bank',
        'Flat \$25 off', 'Credit Cards',
        'On orders above \$150', 'AXIS25',
        'Applicable on a single transaction. Excludes clearance items.'),
    _Offer('IC', Color(0xFFE07A00), 'ICICI Bank',
        '5% cashback', 'Credit Cards & EMI',
        'Up to \$30 · no min spend', 'ICICICB',
        'Cashback credited within 90 days to the source card.'),
    _Offer('SB', Color(0xFF1A6DB5), 'SBI Card',
        'No-cost EMI', '3 / 6 month tenures',
        'On orders above \$200', null,
        'Interest waived by StyleCart. Processing fee may apply by the bank.'),
    _Offer('AM', Color(0xFF2E7D6B), 'Amex',
        '12% instant discount', 'Membership Rewards Cards',
        'Up to \$60 · min spend \$250', 'AMEX12',
        'Valid for Amex cardholders only. Limited-period offer.'),
  ];

  int _open = -1;

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
                  itemCount: _offers.length + 1,
                  itemBuilder: (BuildContext _, int i) {
                    if (i == 0) return _intro();
                    return _offerCard(_offers[i - 1], i - 1);
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

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

  Widget _intro() {
    return const Padding(
      padding: EdgeInsets.only(left: 4, bottom: 14, right: 8),
      child: Text(
        'Pay with an eligible card to apply these savings automatically at '
        'checkout.',
        style: TextStyle(
          fontFamily: _font,
          fontSize: 13,
          fontWeight: FontWeight.w500,
          height: 1.4,
          color: _muted,
        ),
      ),
    );
  }

  Widget _offerCard(_Offer o, int i) {
    final bool open = _open == i;
    return Container(
      margin: const EdgeInsets.only(bottom: 12),
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(14),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                CustomPaint(
                  size: const Size(46, 46),
                  painter: _MonogramPainter(o.initials, o.tint),
                ),
                const SizedBox(width: 14),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(
                        o.headline,
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 15.5,
                          fontWeight: FontWeight.w800,
                          letterSpacing: -0.2,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(height: 3),
                      Text(
                        '${o.bank} · ${o.cards}',
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 12.5,
                          fontWeight: FontWeight.w600,
                          color: _muted,
                        ),
                      ),
                      const SizedBox(height: 8),
                      Wrap(
                        spacing: 8,
                        runSpacing: 8,
                        children: <Widget>[
                          Container(
                            padding: const EdgeInsets.symmetric(
                                horizontal: 9, vertical: 5),
                            decoration: BoxDecoration(
                              color: _surface,
                              borderRadius: BorderRadius.circular(8),
                            ),
                            child: Text(
                              o.terms,
                              style: const TextStyle(
                                fontFamily: _font,
                                fontSize: 11.5,
                                fontWeight: FontWeight.w700,
                                color: _ink,
                              ),
                            ),
                          ),
                          if (o.code != null)
                            Container(
                              padding: const EdgeInsets.symmetric(
                                  horizontal: 9, vertical: 5),
                              decoration: BoxDecoration(
                                borderRadius: BorderRadius.circular(8),
                                border: Border.all(
                                  color: _brand.withValues(alpha: 0.4),
                                ),
                              ),
                              child: Text(
                                o.code!,
                                style: const TextStyle(
                                  fontFamily: _font,
                                  fontSize: 11.5,
                                  fontWeight: FontWeight.w800,
                                  letterSpacing: 0.6,
                                  color: _brand,
                                ),
                              ),
                            ),
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
          GestureDetector(
            onTap: () => setState(() => _open = open ? -1 : i),
            behavior: HitTestBehavior.opaque,
            child: Container(
              decoration: const BoxDecoration(
                border: Border(top: BorderSide(color: _hairline)),
              ),
              padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
              child: Row(
                children: <Widget>[
                  Text(
                    'Terms',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 12.5,
                      fontWeight: FontWeight.w700,
                      color: open ? _brand : _muted,
                    ),
                  ),
                  const Spacer(),
                  Icon(
                    open
                        ? Icons.keyboard_arrow_up_rounded
                        : Icons.keyboard_arrow_down_rounded,
                    size: 20,
                    color: open ? _brand : _faint,
                  ),
                ],
              ),
            ),
          ),
          if (open)
            Padding(
              padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
              child: Align(
                alignment: Alignment.centerLeft,
                child: Text(
                  o.detail,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w500,
                    height: 1.45,
                    color: _muted,
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class _Offer {
  const _Offer(this.initials, this.tint, this.bank, this.headline, this.cards,
      this.terms, this.code, this.detail);
  final String initials;
  final Color tint;
  final String bank;
  final String headline;
  final String cards;
  final String terms;
  final String? code;
  final String detail;
}

/// A painted rounded-square bank monogram (tinted fill + white initials).
class _MonogramPainter extends CustomPainter {
  _MonogramPainter(this.initials, this.tint);
  final String initials;
  final Color tint;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect box = RRect.fromRectAndRadius(
      Offset.zero & size,
      const Radius.circular(12),
    );
    canvas.drawRRect(box, Paint()..color = tint.withValues(alpha: 0.12));
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(0, 0, size.width, size.height),
        const Radius.circular(12),
      ),
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.2
        ..color = tint.withValues(alpha: 0.30),
    );

    final TextPainter tp = TextPainter(
      text: TextSpan(
        text: initials,
        style: TextStyle(
          fontFamily: 'Manrope',
          fontSize: size.width * 0.36,
          fontWeight: FontWeight.w800,
          letterSpacing: 0.3,
          color: tint,
        ),
      ),
      textDirection: TextDirection.ltr,
    )..layout();
    tp.paint(
      canvas,
      Offset((size.width - tp.width) / 2, (size.height - tp.height) / 2),
    );
  }

  @override
  bool shouldRepaint(_MonogramPainter old) =>
      old.initials != initials || old.tint != tint;
}

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-bank

2. AI agent (MCP)

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

FAQ

Is this bank offers screen free to use in a commercial app?

Yes. FlutterKit screens are free, commercial use included — ship this offers list in a store app, marketplace or checkout flow as-is or restyled. Copy the code from this page or install it via the CLI; no attribution required.

How do I load real offers from my backend instead of the const list?

Replace the `static const List<_Offer> _offers` with a field passed into the constructor (or fetched in `initState`), and map your API response into `_Offer` values — initials, a tint, and the display strings. Keep `code` nullable so EMI-style offers without a coupon still drop the chip automatically, and keep `_open = -1` as local state since expansion is purely visual.

Can I make the promo code copy to the clipboard on tap?

Wrap the code chip's `Container` in a `GestureDetector` (or `InkWell`) and call `Clipboard.setData(ClipboardData(text: o.code!))` from `package:flutter/services.dart`, then confirm with a `SnackBar`. The chip is already visually styled as the actionable element — coral outline and spaced uppercase text — so making it tappable matches what shoppers expect.

What packages and fonts does this screen need?

No pub packages at all — the vendored code imports only `package:flutter/material.dart`, and the bank marks are painted, not image assets. The text expects a Manrope font family: declare Manrope under `fonts:` in your pubspec with the font files bundled, or change the single `_font` constant to a family you already ship.

Which Flutter version does this code require?

Flutter 3.27 or newer, because the code chip border and the monogram painter use `Color.withValues(alpha: ...)`. On an older SDK, replace those three calls with `withOpacity(...)`; the `super.key` constructor parameter only needs Dart 2.17, so nothing else is version-sensitive.

Related screens