E-commerce68 views

How to Build a Membership Active Screen in Flutter (Full Code + Preview)

Once someone pays for a membership, the screen they land on has to keep proving the subscription is worth its price. This tutorial builds StyleCart's active-membership home in Flutter: a dark gradient member card with a gold tier badge, an Active pill and a painter-drawn QR seeded from the member ID, a green savings summary that puts a dollar figure on the year, a checked list of unlocked perks, a billing row with the renewal date and masked card, and a pinned Manage button with a quiet Cancel link. One file, no packages, callbacks only.

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

What you'll build

  • A dark gradient member card carrying a gold tier icon, a green Active pill, the member identity block and a white QR tile
  • A decorative QR painted from the member ID string — hashed cells plus the three classic finder squares, no asset and no QR package
  • A green-tinted savings card that leads with "$184 saved this year" and says where the number comes from
  • A benefits list generated from a const string list with brand-tinted check circles
  • Three deliberately unequal subscription controls: a pinned coral Manage bar, a small Edit link on the billing row, and a red text-only Cancel

Step-by-step build

1

Create the file

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

Callbacks, tokens, and benefits as data

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

/// StyleCart — Membership active.
///
/// The member's home for an active StyleCart Plus subscription: a dark
/// membership card with painted member QR + tier + member-since, a savings
/// summary (this year saved), an unlocked-benefits list, a renewal/billing
/// row, and manage / cancel actions. A pinned bar opens billing.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (member QR). Exposes callbacks only.
class EcomMembershipActiveScreen extends StatelessWidget {
  const EcomMembershipActiveScreen({
    super.key,
    this.onBack,
    this.onManage,
    this.onCancel,
  });

  final VoidCallback? onBack;
  final VoidCallback? onManage;
  final VoidCallback? onCancel;

  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _danger = Color(0xFFE5484D);
  static const Color _gold = Color(0xFFE9C46A);

  static const List<String> _benefits = <String>[
    'Free express shipping on every order',
    'Early 24h access to all sales & drops',
    '2× reward points on purchases',
    'Extended 30-day free returns',
    'Personal stylist chat',
  ];

The whole screen is a `StatelessWidget` — an account page shows facts and hands decisions upward, so it exposes exactly three callbacks: `onBack`, `onManage`, `onCancel`. The palette is Airbnb-flavoured: `_brand` coral `0xFFFF385C` for actions, `_success` green for money saved, `_danger` red reserved solely for the cancel link, and `_gold` `0xFFE9C46A` kept for the tier badge so 'premium' has its own colour. The five perks live in a `static const List<String> _benefits` rather than five hand-written rows, which means editing the copy or adding a sixth perk touches one list, not the widget tree.

Scaffold with a pinned bar, and a header that is just a title

ecom_membership_active_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(
                  padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
                  children: <Widget>[
                    _card(),
                    const SizedBox(height: 18),
                    _savings(),
                    const SizedBox(height: 20),
                    _benefitsList(),
                    const SizedBox(height: 20),
                    _billingRow(),
                    const SizedBox(height: 12),
                    _manageRow(),
                  ],
                ),
              ),
              _bottomBar(),
            ],
          ),
        ),
      ),
    );
  }

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

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the screen carries its own theme into any host app, then lays out a `Column` of header, `Expanded` `ListView`, and `_bottomBar()`. Because the bottom bar sits outside the `Expanded`, the Manage button stays pinned while the card, savings, benefits and billing content scroll behind it. The `ListView` uses `EdgeInsets.fromLTRB(20, 4, 20, 20)` side padding while the header row starts at only 8px left — the `IconButton`'s built-in touch padding makes up the difference so the arrow optically aligns with the content edge. The 'My membership' title is 20px `w800` with `letterSpacing: -0.3`, the same tight display style used across the StyleCart set.

The dark member card

ecom_membership_active_screen.dart
  Widget _card() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(20),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[Color(0xFF2A2A2E), Color(0xFF111114)],
        ),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Row(
            children: <Widget>[
              const Icon(Icons.workspace_premium_rounded,
                  size: 22, color: _gold),
              const SizedBox(width: 8),
              const Text(
                'StyleCart Plus',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 16,
                  fontWeight: FontWeight.w800,
                  color: Colors.white,
                ),
              ),
              const Spacer(),
              Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
                decoration: BoxDecoration(
                  color: _success.withValues(alpha: 0.22),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: const Text(
                  'Active',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 11.5,
                    fontWeight: FontWeight.w800,
                    color: Color(0xFF5FD08A),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 20),
          Row(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: <Widget>[
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: const <Widget>[
                    Text(
                      'MEMBER',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 10,
                        fontWeight: FontWeight.w700,
                        letterSpacing: 1.5,
                        color: Colors.white38,
                      ),
                    ),
                    SizedBox(height: 4),
                    Text(
                      'Maya Sharma',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 18,
                        fontWeight: FontWeight.w800,
                        color: Colors.white,
                      ),
                    ),
                    SizedBox(height: 10),
                    Text(
                      'Member since Jun 2024',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: Colors.white60,
                      ),
                    ),
                    Text(
                      'ID · SCP-4821-9930',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: Colors.white60,
                      ),
                    ),
                  ],
                ),
              ),
              Container(
                padding: const EdgeInsets.all(8),
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: CustomPaint(
                  size: const Size(72, 72),
                  painter: _QrPainter('SCP-4821-9930'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

The card gets a top-left-to-bottom-right `LinearGradient` from `0xFF2A2A2E` to `0xFF111114` inside a 20px radius — a near-black wallet-pass look that makes the one dark surface on an otherwise white screen read as a physical card. Its first row spells out status: `Icons.workspace_premium_rounded` in `_gold`, the 'StyleCart Plus' wordmark, then a `Spacer()` pushing an 'Active' pill whose background is `_success.withValues(alpha: 0.22)` with brighter `0xFF5FD08A` text, because full-opacity green text would vanish on translucent green over black. The second row uses `crossAxisAlignment: CrossAxisAlignment.end` so the identity column (a 10px letter-spaced 'MEMBER' eyebrow in `Colors.white38`, the name at 18px, then member-since and `ID · SCP-4821-9930` in `Colors.white60`) bottom-aligns with the QR. The QR itself sits in an 8px-padded white rounded container — the quiet zone a scanner-style motif needs to read against the dark card — as a `CustomPaint` of `Size(72, 72)` seeded with the same member ID shown in text.

Putting a number on the membership

ecom_membership_active_screen.dart
  Widget _savings() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: _success.withValues(alpha: 0.08),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _success.withValues(alpha: 0.20)),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.savings_outlined, size: 26, color: _success),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: const <Widget>[
                Text(
                  '\$184 saved this year',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 15.5,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 2),
                Text(
                  'On shipping, returns & member discounts',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

`_savings` is the retention argument: '$184 saved this year' in 15.5px `w800` ink, with the quieter 12px line 'On shipping, returns & member discounts' explaining what the figure counts. The card is tinted `_success.withValues(alpha: 0.08)` with a `Border.all` at alpha `0.20` — the border earns the panel more weight than a flat tint because this is the row that justifies the renewal price. An `Icons.savings_outlined` piggy bank at 26 leads the row, and the text column sits in `Expanded` so a longer localised subtitle wraps instead of overflowing.

Benefits from a collection-for

ecom_membership_active_screen.dart
  Widget _benefitsList() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        const Text(
          'Your benefits',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 16,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.2,
            color: _ink,
          ),
        ),
        const SizedBox(height: 10),
        for (final String b in _benefits)
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 6),
            child: Row(
              children: <Widget>[
                Container(
                  width: 22,
                  height: 22,
                  decoration: BoxDecoration(
                    color: _brand.withValues(alpha: 0.12),
                    shape: BoxShape.circle,
                  ),
                  child: const Icon(Icons.check_rounded,
                      size: 14, color: _brand),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Text(
                    b,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w600,
                      color: _ink,
                    ),
                  ),
                ),
              ],
            ),
          ),
      ],
    );
  }

`_benefitsList` renders the perks with `for (final String b in _benefits)` directly inside the children list — no `ListView.builder` needed for five static rows. Each row leads with a 22px circle at `_brand.withValues(alpha: 0.12)` holding a 14px `Icons.check_rounded` in full `_brand`: the tinted-disc-plus-solid-icon pairing is the same two-layer accent treatment as the Active pill, applied in coral. Vertical padding of 6 on each row yields a 12px rhythm between perks, and the label sits in `Expanded` so 'Free express shipping on every order' can wrap on narrow phones without pushing its check circle out of line.

Billing row and a cancel link that whispers

ecom_membership_active_screen.dart
  Widget _billingRow() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.event_repeat_rounded, size: 22, color: _ink),
          const SizedBox(width: 12),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  'Renews 1 Jul 2026',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 14,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 2),
                Text(
                  '\$67/yr · Visa •••• 4821',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
          GestureDetector(
            onTap: onManage,
            child: const Text(
              'Edit',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w800,
                color: _brand,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _manageRow() {
    return GestureDetector(
      onTap: onCancel,
      behavior: HitTestBehavior.opaque,
      child: const Padding(
        padding: EdgeInsets.symmetric(vertical: 8),
        child: Center(
          child: Text(
            'Cancel membership',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w700,
              color: _danger,
            ),
          ),
        ),
      ),
    );
  }

`_billingRow` is a flat `_surface` panel answering the two pre-cancellation questions in one glance: 'Renews 1 Jul 2026' in bold ink, '$67/yr · Visa •••• 4821' in muted 12px underneath, with `Icons.event_repeat_rounded` signalling recurrence. 'Edit' is a bare coral `GestureDetector` text rather than a button — changing a card is a legitimate but secondary action, so it gets colour without chrome. `_manageRow` below it is the cancel affordance: red 13.5px text, centred, with `behavior: HitTestBehavior.opaque` so the taps land across the full padded width even though only the text paints. Cancel is findable but visually the least prominent interactive element on the screen — exactly the ranking an active-membership page wants.

The pinned Manage bar

ecom_membership_active_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: onManage,
        child: Container(
          height: 56,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: _brand,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: const Text(
            'Manage subscription',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 15.5,
              fontWeight: FontWeight.w800,
              color: Colors.white,
            ),
          ),
        ),
      ),
    );
  }
}

`_bottomBar` is an 88px `Container` on `_canvas` white with a `_hairline` top border, so the boundary between scrolling content and the fixed action is a single 1px line rather than a shadow. Inside, the button is a hand-rolled `GestureDetector` around a 56px container filled `_brand` with `BorderRadius.circular(9999)` — the effectively-infinite radius produces a true pill at any height without computing height/2. 'Manage subscription' triggers the same `onManage` callback as the billing row's Edit link, so both routes converge on one handler in the host app.

Painting the seeded QR

ecom_membership_active_screen.dart
class _QrPainter extends CustomPainter {
  _QrPainter(this.seed);
  final String seed;

  @override
  void paint(Canvas canvas, Size size) {
    const int n = 11;
    final double cell = size.width / n;
    final Paint dark = Paint()..color = const Color(0xFF111114);

    int h = 0;
    for (int i = 0; i < seed.length; i++) {
      h = (h * 31 + seed.codeUnitAt(i)) & 0x7fffffff;
    }

    bool finder(int r, int col) {
      bool inBox(int br, int bc) =>
          r >= br && r < br + 3 && col >= bc && col < bc + 3;
      return inBox(0, 0) || inBox(0, n - 3) || inBox(n - 3, 0);
    }

    for (int r = 0; r < n; r++) {
      for (int col = 0; col < n; col++) {
        if (finder(r, col)) continue;
        h = (h * 1103515245 + 12345) & 0x7fffffff;
        if ((h >> 8) % 100 < 48) {
          canvas.drawRect(
            Rect.fromLTWH(col * cell, r * cell, cell * 0.92, cell * 0.92),
            dark,
          );
        }
      }
    }

    void drawFinder(int br, int bc) {
      final Rect outer =
          Rect.fromLTWH(bc * cell, br * cell, cell * 3, cell * 3);
      canvas.drawRect(outer, dark);
      canvas.drawRect(outer.deflate(cell * 0.5), Paint()..color = Colors.white);
      canvas.drawRect(
        Rect.fromLTWH((bc + 1) * cell, (br + 1) * cell, cell, cell),
        dark,
      );
    }

    drawFinder(0, 0);
    drawFinder(0, n - 3);
    drawFinder(n - 3, 0);
  }

  @override
  bool shouldRepaint(_QrPainter old) => old.seed != seed;
}

`_QrPainter` fakes a QR convincingly in ~50 lines. It hashes the seed string with the classic `h * 31 + codeUnit` loop, then walks an 11×11 grid advancing `h` through a linear congruential generator (`h * 1103515245 + 12345`) per cell and paints a block when `(h >> 8) % 100 < 48` — a deterministic ~48% fill, so the same member ID always draws the same pattern. The `finder` closure skips the three 3×3 corner zones during the random pass, and `drawFinder` rebuilds them properly: a solid 3-cell square, a white square deflated by half a cell, and a centre cell — the concentric look every eye recognises as QR. Cells draw at `cell * 0.92` to leave hairline gutters, and `shouldRepaint` compares seeds so the canvas only repaints when the member ID changes. The doc comment is honest: this is a visual motif, not a scannable code.

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 — Membership active.
///
/// The member's home for an active StyleCart Plus subscription: a dark
/// membership card with painted member QR + tier + member-since, a savings
/// summary (this year saved), an unlocked-benefits list, a renewal/billing
/// row, and manage / cancel actions. A pinned bar opens billing.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (member QR). Exposes callbacks only.
class EcomMembershipActiveScreen extends StatelessWidget {
  const EcomMembershipActiveScreen({
    super.key,
    this.onBack,
    this.onManage,
    this.onCancel,
  });

  final VoidCallback? onBack;
  final VoidCallback? onManage;
  final VoidCallback? onCancel;

  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _danger = Color(0xFFE5484D);
  static const Color _gold = Color(0xFFE9C46A);

  static const List<String> _benefits = <String>[
    'Free express shipping on every order',
    'Early 24h access to all sales & drops',
    '2× reward points on purchases',
    'Extended 30-day free returns',
    'Personal stylist chat',
  ];

  @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(
                  padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
                  children: <Widget>[
                    _card(),
                    const SizedBox(height: 18),
                    _savings(),
                    const SizedBox(height: 20),
                    _benefitsList(),
                    const SizedBox(height: 20),
                    _billingRow(),
                    const SizedBox(height: 12),
                    _manageRow(),
                  ],
                ),
              ),
              _bottomBar(),
            ],
          ),
        ),
      ),
    );
  }

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

  Widget _card() {
    return Container(
      padding: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(20),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[Color(0xFF2A2A2E), Color(0xFF111114)],
        ),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Row(
            children: <Widget>[
              const Icon(Icons.workspace_premium_rounded,
                  size: 22, color: _gold),
              const SizedBox(width: 8),
              const Text(
                'StyleCart Plus',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 16,
                  fontWeight: FontWeight.w800,
                  color: Colors.white,
                ),
              ),
              const Spacer(),
              Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
                decoration: BoxDecoration(
                  color: _success.withValues(alpha: 0.22),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: const Text(
                  'Active',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 11.5,
                    fontWeight: FontWeight.w800,
                    color: Color(0xFF5FD08A),
                  ),
                ),
              ),
            ],
          ),
          const SizedBox(height: 20),
          Row(
            crossAxisAlignment: CrossAxisAlignment.end,
            children: <Widget>[
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: const <Widget>[
                    Text(
                      'MEMBER',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 10,
                        fontWeight: FontWeight.w700,
                        letterSpacing: 1.5,
                        color: Colors.white38,
                      ),
                    ),
                    SizedBox(height: 4),
                    Text(
                      'Maya Sharma',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 18,
                        fontWeight: FontWeight.w800,
                        color: Colors.white,
                      ),
                    ),
                    SizedBox(height: 10),
                    Text(
                      'Member since Jun 2024',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: Colors.white60,
                      ),
                    ),
                    Text(
                      'ID · SCP-4821-9930',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: Colors.white60,
                      ),
                    ),
                  ],
                ),
              ),
              Container(
                padding: const EdgeInsets.all(8),
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: CustomPaint(
                  size: const Size(72, 72),
                  painter: _QrPainter('SCP-4821-9930'),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _savings() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: _success.withValues(alpha: 0.08),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _success.withValues(alpha: 0.20)),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.savings_outlined, size: 26, color: _success),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: const <Widget>[
                Text(
                  '\$184 saved this year',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 15.5,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 2),
                Text(
                  'On shipping, returns & member discounts',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _benefitsList() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        const Text(
          'Your benefits',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 16,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.2,
            color: _ink,
          ),
        ),
        const SizedBox(height: 10),
        for (final String b in _benefits)
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 6),
            child: Row(
              children: <Widget>[
                Container(
                  width: 22,
                  height: 22,
                  decoration: BoxDecoration(
                    color: _brand.withValues(alpha: 0.12),
                    shape: BoxShape.circle,
                  ),
                  child: const Icon(Icons.check_rounded,
                      size: 14, color: _brand),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Text(
                    b,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w600,
                      color: _ink,
                    ),
                  ),
                ),
              ],
            ),
          ),
      ],
    );
  }

  Widget _billingRow() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Row(
        children: <Widget>[
          const Icon(Icons.event_repeat_rounded, size: 22, color: _ink),
          const SizedBox(width: 12),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  'Renews 1 Jul 2026',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 14,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                SizedBox(height: 2),
                Text(
                  '\$67/yr · Visa •••• 4821',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
          GestureDetector(
            onTap: onManage,
            child: const Text(
              'Edit',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w800,
                color: _brand,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _manageRow() {
    return GestureDetector(
      onTap: onCancel,
      behavior: HitTestBehavior.opaque,
      child: const Padding(
        padding: EdgeInsets.symmetric(vertical: 8),
        child: Center(
          child: Text(
            'Cancel membership',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13.5,
              fontWeight: FontWeight.w700,
              color: _danger,
            ),
          ),
        ),
      ),
    );
  }

  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: onManage,
        child: Container(
          height: 56,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: _brand,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: const Text(
            'Manage subscription',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 15.5,
              fontWeight: FontWeight.w800,
              color: Colors.white,
            ),
          ),
        ),
      ),
    );
  }
}

/// A decorative seeded QR — painted blocks derived from the member code plus
/// the three finder squares. Not scannable; purely a visual motif.
class _QrPainter extends CustomPainter {
  _QrPainter(this.seed);
  final String seed;

  @override
  void paint(Canvas canvas, Size size) {
    const int n = 11;
    final double cell = size.width / n;
    final Paint dark = Paint()..color = const Color(0xFF111114);

    int h = 0;
    for (int i = 0; i < seed.length; i++) {
      h = (h * 31 + seed.codeUnitAt(i)) & 0x7fffffff;
    }

    bool finder(int r, int col) {
      bool inBox(int br, int bc) =>
          r >= br && r < br + 3 && col >= bc && col < bc + 3;
      return inBox(0, 0) || inBox(0, n - 3) || inBox(n - 3, 0);
    }

    for (int r = 0; r < n; r++) {
      for (int col = 0; col < n; col++) {
        if (finder(r, col)) continue;
        h = (h * 1103515245 + 12345) & 0x7fffffff;
        if ((h >> 8) % 100 < 48) {
          canvas.drawRect(
            Rect.fromLTWH(col * cell, r * cell, cell * 0.92, cell * 0.92),
            dark,
          );
        }
      }
    }

    void drawFinder(int br, int bc) {
      final Rect outer =
          Rect.fromLTWH(bc * cell, br * cell, cell * 3, cell * 3);
      canvas.drawRect(outer, dark);
      canvas.drawRect(outer.deflate(cell * 0.5), Paint()..color = Colors.white);
      canvas.drawRect(
        Rect.fromLTWH((bc + 1) * cell, (br + 1) * cell, cell, cell),
        dark,
      );
    }

    drawFinder(0, 0);
    drawFinder(0, n - 3);
    drawFinder(n - 3, 0);
  }

  @override
  bool shouldRepaint(_QrPainter old) => old.seed != seed;
}

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-membership-active

2. AI agent (MCP)

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

FAQ

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

Yes. FlutterKit screens are free, commercial use included — copy the code from this page or install it with the CLI command and ship it in your subscription or loyalty app. No attribution and no sign-up required.

Does this screen need any packages, or a QR library?

No pub packages at all — the only import is `package:flutter/material.dart`, and the QR is drawn by the included `_QrPainter`, so you don't need `qr_flutter` for the visual. The only asset is the Manrope font family, bundled and referenced via `fontFamily: 'Manrope'`; declare it in your `pubspec.yaml` fonts section.

Which Flutter version does this code require?

Flutter 3.27 or newer, because the pills and tints use `Color.withValues(alpha: ...)` in several places. On an older SDK, replace each `withValues(alpha: x)` with `withOpacity(x)`; the constructor also uses `super.key`, which needs Dart 2.17+ (Flutter 3.0+).

Can I make the member QR actually scannable?

The painted QR is deliberately decorative — its blocks come from a hash of the ID, not QR encoding, so no scanner will read it. For a real code, swap the `CustomPaint` inside the white tile for a `QrImageView` from the `qr_flutter` package with the member ID as data; the 8px white padding around it already gives the quiet zone scanners want.

How do I show real member data instead of Maya Sharma?

Promote the literals to constructor parameters — `memberName`, `memberId`, `memberSince`, `renewalLine`, `savedThisYear` — and pass `memberId` into `_QrPainter(...)` so the card pattern stays tied to the ID it displays. The `_benefits` list can likewise become a `List<String>` parameter defaulting to the current five perks if tiers unlock different sets.

Related screens