E-commerce73 views

How to Build a Membership Plans Screen with a Monthly/Yearly Toggle in Flutter (Full Code + Preview)

A membership upsell has to answer two questions at once: which tier, and how often am I billed? This tutorial builds StyleCart Plus's plan picker in Flutter, where two pieces of state, a yearly flag and a selected index, drive everything: a pill billing toggle, three tier cards with radios and Most popular or Best value tags, a perk list that shows what each tier withholds, and a pinned subscribe bar whose label rewrites itself with the chosen plan and cadence. One file, two painters, callbacks only.

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

What you'll build

  • ✓A pill-shaped Monthly / Yearly toggle built from two Expanded halves, where the active half lifts onto white with a soft shadow
  • ✓Three tier cards generated from a const `_plans` list, each with a custom radio, a tag pill and a price that switches between /mo and /yr
  • ✓Perk lists that show locked benefits greyed out with a painted dash, so every tier advertises the tier above it
  • ✓A pinned subscribe bar whose label is built from the selected plan and cadence, and which goes inert when Free is selected
  • ✓A painted shield-and-star crest whose star is plotted with a hand-rolled sine, with no `dart:math` import

Step-by-step build

1

Create the file

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

Pricing tiers as one const table

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

/// StyleCart — StyleCart Plus membership plans.
///
/// The subscription upsell: a painted crest hero, a monthly/yearly billing
/// toggle (with a "save 20%" pill), three selectable tier cards (Free / Plus /
/// Premium) each listing included benefits with painted check/lock marks, a
/// savings summary, and a pinned subscribe bar reflecting the chosen tier +
/// cadence.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (crest, benefit marks). Exposes callbacks only.
class EcomMembershipPlansScreen extends StatefulWidget {
  const EcomMembershipPlansScreen({super.key, this.onBack, this.onSubscribe});

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

  @override
  State<EcomMembershipPlansScreen> createState() =>
      _EcomMembershipPlansScreenState();
}

class _EcomMembershipPlansScreenState extends State<EcomMembershipPlansScreen> {
  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<_Plan> _plans = <_Plan>[
    _Plan('Free', 0, 0, 'Your current plan', <_Perk>[
      _Perk('Standard delivery', true),
      _Perk('Order tracking', true),
      _Perk('Free returns within 14 days', true),
      _Perk('Free express shipping', false),
      _Perk('Early sale access', false),
      _Perk('2× reward points', false),
    ]),
    _Plan('Plus', 7, 67, 'Most popular', <_Perk>[
      _Perk('Everything in Free', true),
      _Perk('Free express shipping', true),
      _Perk('Early sale access', true),
      _Perk('Extended 30-day returns', true),
      _Perk('2× reward points', false),
      _Perk('Personal stylist chat', false),
    ]),
    _Plan('Premium', 14, 134, 'Best value', <_Perk>[
      _Perk('Everything in Plus', true),
      _Perk('2× reward points', true),
      _Perk('Personal stylist chat', true),
      _Perk('Free same-day delivery', true),
      _Perk('Priority support', true),
      _Perk('Exclusive member drops', true),
    ]),
  ];

The screen takes just two callbacks, `onBack` and `onSubscribe`, and the latter is a `ValueChanged<String>` rather than a `VoidCallback`, so the host is told which plan was picked. The whole pricing model is the `static const List<_Plan> _plans`: each entry holds a name, a monthly and a yearly integer price, a tag string and six `_Perk(label, included)` rows. The yearly figures are typed in by hand rather than calculated. Plus is $7/mo against $67/yr, and twelve months at $7 would be $84, so $67 is a 20.2% saving. Premium's $134 against 12 × $14 = $168 gives the same ratio. That keeps the 'save 20%' label on the toggle honest, but only as long as anyone editing the prices keeps the ratio. Because the perk lists are written per tier, the six rows can differ from card to card. Free locks 'Free express shipping', which Plus includes, and the two perks Plus locks, '2× reward points' and 'Personal stylist chat', are exactly the ones Premium unlocks.

Two fields of state and a derived plan

ecom_membership_plans_screen.dart
  int _selected = 1;

  @override
  Widget build(BuildContext context) {
    final _Plan plan = _plans[_selected];
    final bool free = plan.monthly == 0;
    final num price = _yearly ? plan.yearly : plan.monthly;

    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, 0, 20, 20),
                  children: <Widget>[
                    _hero(),
                    const SizedBox(height: 18),
                    _billingToggle(),
                    const SizedBox(height: 18),
                    for (int i = 0; i < _plans.length; i++) _planCard(i),
                    const SizedBox(height: 6),
                    _terms(),
                  ],
                ),
              ),
              _bottomBar(plan, free, price),
            ],
          ),
        ),
      ),
    );
  }

The only mutable state is `bool _yearly = false` and `int _selected = 1`. Starting on index 1 means the screen opens with Plus, the 'Most popular' tier, already selected, so the subscribe bar is live and priced from the first frame instead of showing a disabled Free state. At the top of `build`, three locals are derived: `plan = _plans[_selected]`, `free = plan.monthly == 0`, and `price = _yearly ? plan.yearly : plan.monthly`. They are passed into `_bottomBar(plan, free, price)` so the bar never reads state on its own. The layout uses the familiar pinned-footer shape: a `Column` with the header, an `Expanded` `ListView` holding the hero, toggle, cards and terms, and the bar outside the scroll. The cards are emitted by `for (int i = 0; i < _plans.length; i++) _planCard(i)`, so a fourth tier only needs a fourth table entry.

Header and painted hero

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

  Widget _hero() {
    return Column(
      children: const <Widget>[
        CustomPaint(size: Size(72, 72), painter: _CrestPainter()),
        SizedBox(height: 14),
        Text(
          'Unlock member perks',
          textAlign: TextAlign.center,
          style: TextStyle(
            fontFamily: _font,
            fontSize: 22,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.4,
            color: _ink,
          ),
        ),
        SizedBox(height: 6),
        Text(
          'Free express shipping, early access to sales and double points — '
          'cancel anytime.',
          textAlign: TextAlign.center,
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13.5,
            fontWeight: FontWeight.w500,
            height: 1.45,
            color: _muted,
          ),
        ),
      ],
    );
  }

The header is a close icon rather than a back arrow, because an upsell is something the shopper should be able to dismiss outright. Beside it the brand name 'StyleCart Plus' sits at 20px `w800`. `_hero()` is a fully `const` column: a 72×72 `CustomPaint` running `_CrestPainter`, then 'Unlock member perks' at 22px `w800` with `letterSpacing: -0.4`, then a 13.5px muted subline at `height: 1.45`. The subline is good sales copy because it names three concrete perks before any price appears, and it ends with 'cancel anytime', which answers the commitment worry before the shopper scrolls to the prices. Because nothing in the hero depends on state, marking it `const` means a toggle tap or card tap never rebuilds the crest or the copy.

The billing toggle

ecom_membership_plans_screen.dart
    return Container(
      height: 46,
      padding: const EdgeInsets.all(4),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(9999),
      ),
      child: Row(
        children: <Widget>[
          _toggleHalf('Monthly', !_yearly, () => setState(() => _yearly = false)),
          _toggleHalf('Yearly · save 20%', _yearly,
              () => setState(() => _yearly = true)),
        ],
      ),
    );
  }

  Widget _toggleHalf(String label, bool active, VoidCallback onTap) {
    return Expanded(
      child: GestureDetector(
        onTap: onTap,
        child: Container(
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: active ? _canvas : Colors.transparent,
            borderRadius: BorderRadius.circular(9999),
            boxShadow: active
                ? <BoxShadow>[
                    BoxShadow(
                      color: Colors.black.withValues(alpha: 0.06),
                      blurRadius: 6,
                      offset: const Offset(0, 2),
                    ),
                  ]
                : null,
          ),
          child: Text(
            label,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13,
              fontWeight: FontWeight.w800,
              color: active ? _ink : _muted,
            ),
          ),
        ),
      ),
    );
  }

`_billingToggle()` is a 46px `_surface` (#F2F2F2) track with a 9999 radius and 4px inner padding. Inside it, two `_toggleHalf` calls each wrap an `Expanded`, so both halves are exactly equal width whatever their labels say. The active half gets a white fill, a full-radius corner and a single `BoxShadow` at `Colors.black.withValues(alpha: 0.06)`, blur 6, offset (0, 2). That small lift makes it read as a thumb sitting on a track, while the inactive half stays transparent with `_muted` text. The 4px padding is what leaves a visible rim of grey around the white thumb. Both halves call `setState` with an explicit value (`_yearly = false` or `true`) instead of toggling, so tapping the half that is already active does nothing. Note there is no sliding animation: the thumb jumps. For a slide, swap in an `AnimatedAlign` over a single white pill.

Tier cards, radios and the price switch

ecom_membership_plans_screen.dart
    final _Plan p = _plans[i];
    final bool selected = _selected == i;
    final bool free = p.monthly == 0;
    final num price = _yearly ? p.yearly : p.monthly;
    return GestureDetector(
      onTap: () => setState(() => _selected = i),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        margin: const EdgeInsets.only(bottom: 12),
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(18),
          border: Border.all(
            color: selected ? _brand : _hairline,
            width: selected ? 1.8 : 1,
          ),
        ),
        child: Column(
          children: <Widget>[
            Row(
              children: <Widget>[
                _radio(selected),
                const SizedBox(width: 12),
                Text(
                  p.name,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 17,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                const SizedBox(width: 8),
                if (p.tag.isNotEmpty)
                  Flexible(
                    child: Container(
                      padding: const EdgeInsets.symmetric(
                          horizontal: 8, vertical: 3),
                      decoration: BoxDecoration(
                        color: free
                            ? _surface
                            : _brand.withValues(alpha: 0.10),
                        borderRadius: BorderRadius.circular(7),
                      ),
                      child: Text(
                        p.tag,
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 10.5,
                          fontWeight: FontWeight.w800,
                          color: free ? _muted : _brand,
                        ),
                      ),
                    ),
                  ),
                const Spacer(),
                _price(free, price),
              ],
            ),
            const SizedBox(height: 14),
            for (final _Perk perk in p.perks) _perkRow(perk),
          ],
        ),
      ),
    );
  }

  Widget _radio(bool on) {
    return Container(
      width: 22,
      height: 22,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(color: on ? _brand : _faint, width: 2),
        color: on ? _brand : Colors.transparent,
      ),
      child: on
          ? const Icon(Icons.check_rounded, size: 14, color: Colors.white)
          : null,
    );
  }

  Widget _price(bool free, num price) {
    if (free) {
      return const Text(
        'Free',
        style: TextStyle(
          fontFamily: _font,
          fontSize: 16,
          fontWeight: FontWeight.w800,
          color: _ink,
        ),
      );
    }
    return Row(
      crossAxisAlignment: CrossAxisAlignment.end,
      children: <Widget>[
        Text(
          '\$$price',
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 18,
            fontWeight: FontWeight.w800,
            color: _ink,
          ),
        ),
        Text(
          _yearly ? '/yr' : '/mo',
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 12,
            fontWeight: FontWeight.w700,
            color: _muted,
          ),
        ),
      ],
    );
  }

Each `_planCard(i)` is a `GestureDetector` around an `AnimatedContainer` with a 150ms duration, so the border change from a 1px `_hairline` to a 1.8px `_brand` coral fades in rather than snapping. The heading row is a custom `_radio`: a 22px circle with a 2px border that fills coral and shows a white 14px `check_rounded` when selected. Next come the plan name at 17px and the tag pill. The pill is tinted by the `free` flag: Free's 'Your current plan' uses neutral grey, while 'Most popular' and 'Best value' get `_brand.withValues(alpha: 0.10)` with coral text, so only the priced tiers look promoted. The pill sits in a `Flexible` with `maxLines: 1` and ellipsis, so on a narrow phone the tag shrinks instead of overflowing into the price. `_price` returns a plain 'Free' for zero-priced plans, and otherwise shows a 18px figure with a 12px '/yr' or '/mo' suffix, bottom-aligned with `CrossAxisAlignment.end`.

Perk rows, terms and the subscribe bar

ecom_membership_plans_screen.dart
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 5),
      child: Row(
        children: <Widget>[
          CustomPaint(
            size: const Size(18, 18),
            painter: _MarkPainter(perk.included),
          ),
          const SizedBox(width: 10),
          Expanded(
            child: Text(
              perk.label,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w600,
                color: perk.included ? _ink : _faint,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _terms() {
    return const Padding(
      padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
      child: Text(
        'Billed automatically until cancelled. Manage or cancel anytime from '
        'your membership settings. Taxes may apply.',
        textAlign: TextAlign.center,
        style: TextStyle(
          fontFamily: _font,
          fontSize: 11.5,
          fontWeight: FontWeight.w500,
          height: 1.4,
          color: _faint,
        ),
      ),
    );
  }

  Widget _bottomBar(_Plan plan, bool free, num price) {
    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: free ? null : () => widget.onSubscribe?.call(plan.name),
        child: Container(
          height: 56,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: free ? _surface : _brand,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: Text(
            free
                ? 'Your current plan'
                : 'Subscribe to ${plan.name} · \$$price${_yearly ? '/yr' : '/mo'}',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 15.5,
              fontWeight: FontWeight.w800,
              color: free ? _faint : Colors.white,
            ),
          ),
        ),
      ),
    );
  }
}

`_perkRow` pairs an 18px `_MarkPainter` with the label, which turns `_faint` (#C1C1C1) when `included` is false. Locked perks are kept and greyed rather than hidden, because each tier then shows what it is missing. The terms note at 11.5px `_faint` states automatic billing, how to cancel and possible taxes. `_bottomBar` is a fixed 88px strip with a top hairline holding a 56px pill. When Free is selected, `onTap` becomes `null`, the pill drops to `_surface`, and the label reads 'Your current plan', since there is nothing to buy. Otherwise the label is interpolated as `'Subscribe to ${plan.name} · \$$price'` plus '/yr' or '/mo'. The button therefore always restates exactly what the tap will commit to. Watch the callback, though: `onSubscribe?.call(plan.name)` sends only the name, so the cadence the label shows never reaches your code.

Value classes and the crest painter

ecom_membership_plans_screen.dart
  const _Plan(this.name, this.monthly, this.yearly, this.tag, this.perks);
  final String name;
  final int monthly;
  final int yearly;
  final String tag;
  final List<_Perk> perks;
}

class _Perk {
  const _Perk(this.label, this.included);
  final String label;
  final bool included;
}

/// A painted membership crest: a rounded shield with a centred star.
class _CrestPainter extends CustomPainter {
  const _CrestPainter();

  static const Color _brand = Color(0xFFFF385C);

  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;
    final Path shield = Path()
      ..moveTo(w * 0.5, h * 0.04)
      ..lineTo(w * 0.92, h * 0.20)
      ..lineTo(w * 0.92, h * 0.55)
      ..quadraticBezierTo(w * 0.92, h * 0.88, w * 0.5, h * 0.98)
      ..quadraticBezierTo(w * 0.08, h * 0.88, w * 0.08, h * 0.55)
      ..lineTo(w * 0.08, h * 0.20)
      ..close();
    canvas.drawPath(shield, Paint()..color = _brand.withValues(alpha: 0.12));
    canvas.drawPath(
      shield,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2
        ..color = _brand,
    );

    // Centre star.
    final Path star = Path();
    final Offset c = Offset(w * 0.5, h * 0.48);
    final double outer = w * 0.22;
    final double inner = w * 0.10;
    const double pi = 3.141592653589793;
    double rot = -pi / 2;
    for (int i = 0; i < 5; i++) {
      star.lineTo(c.dx + outer * _cos(rot), c.dy + outer * _sin(rot));
      rot += pi / 5;
      star.lineTo(c.dx + inner * _cos(rot), c.dy + inner * _sin(rot));
      rot += pi / 5;
    }
    star.close();
    canvas.drawPath(star, Paint()..color = _brand);
  }

  static double _cos(double x) => _sin(x + 1.5707963267948966);
  static double _sin(double x) {
    x %= 6.283185307179586;
    if (x > 3.141592653589793) x -= 6.283185307179586;
    final double x2 = x * x;
    return x * (1 - x2 / 6 + x2 * x2 / 120 - x2 * x2 * x2 / 5040);
  }

  @override
  bool shouldRepaint(_CrestPainter old) => false;
}

`_Plan` and `_Perk` are tiny const classes, which is what lets the whole pricing table be `static const`. `_CrestPainter` draws a shield as one `Path`: a point at 4% height, straight shoulders at 20%, and two `quadraticBezierTo` curves meeting at 98% for the rounded base. That path is filled at 12% coral, then stroked at 2px in full coral. The star is plotted by alternating outer (`w * 0.22`) and inner (`w * 0.10`) radii every `pi / 5`, starting at `-pi / 2` so one point faces straight up. To avoid importing `dart:math`, the painter defines its own `_sin` as a Taylor series to the x⁷ term after wrapping the angle into ±π, with `_cos(x) = _sin(x + π/2)`. That is accurate enough for a 72px icon. One real flaw: the star path starts with `lineTo` and has no `moveTo`, so it begins at the canvas origin and fills a thin coral wedge from the top-left corner. The FAQ covers the one-line fix.

The included / locked mark

ecom_membership_plans_screen.dart
/// when not.
class _MarkPainter extends CustomPainter {
  _MarkPainter(this.included);
  final bool included;

  static const Color _brand = Color(0xFFFF385C);
  static const Color _faint = Color(0xFFC1C1C1);

  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = size.center(Offset.zero);
    final double r = size.width / 2;
    if (included) {
      canvas.drawCircle(c, r, Paint()..color = _brand.withValues(alpha: 0.14));
      final Paint tick = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..color = _brand;
      final Path p = Path()
        ..moveTo(size.width * 0.30, size.height * 0.52)
        ..lineTo(size.width * 0.44, size.height * 0.66)
        ..lineTo(size.width * 0.72, size.height * 0.34);
      canvas.drawPath(p, tick);
    } else {
      canvas.drawCircle(c, r,
          Paint()..color = _faint.withValues(alpha: 0.20));
      canvas.drawLine(
        Offset(size.width * 0.32, size.height * 0.5),
        Offset(size.width * 0.68, size.height * 0.5),
        Paint()
          ..strokeWidth = 2
          ..strokeCap = StrokeCap.round
          ..color = _faint,
      );
    }
  }

  @override
  bool shouldRepaint(_MarkPainter old) => old.included != included;
}

`_MarkPainter` takes a single `included` flag and draws a circle of radius `size.width / 2` in either state, so locked and included rows line up exactly. Included gets a 14%-alpha coral disc with a round-capped, round-joined 2px tick running from (0.30, 0.52) down to (0.44, 0.66) and up to (0.72, 0.34). The short leg and long leg give it the usual check shape. Not included gets a 20%-alpha grey disc with a horizontal grey dash from 32% to 68% of the width. The doc comment and the registry call this a lock, but no padlock is drawn, only a dash. Unlike the crest, this painter takes a parameter, so `shouldRepaint` compares `old.included != included`. That is correct, and it means switching cards only repaints marks whose state actually changed.

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 — StyleCart Plus membership plans.
///
/// The subscription upsell: a painted crest hero, a monthly/yearly billing
/// toggle (with a "save 20%" pill), three selectable tier cards (Free / Plus /
/// Premium) each listing included benefits with painted check/lock marks, a
/// savings summary, and a pinned subscribe bar reflecting the chosen tier +
/// cadence.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (crest, benefit marks). Exposes callbacks only.
class EcomMembershipPlansScreen extends StatefulWidget {
  const EcomMembershipPlansScreen({super.key, this.onBack, this.onSubscribe});

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

  @override
  State<EcomMembershipPlansScreen> createState() =>
      _EcomMembershipPlansScreenState();
}

class _EcomMembershipPlansScreenState extends State<EcomMembershipPlansScreen> {
  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<_Plan> _plans = <_Plan>[
    _Plan('Free', 0, 0, 'Your current plan', <_Perk>[
      _Perk('Standard delivery', true),
      _Perk('Order tracking', true),
      _Perk('Free returns within 14 days', true),
      _Perk('Free express shipping', false),
      _Perk('Early sale access', false),
      _Perk('2× reward points', false),
    ]),
    _Plan('Plus', 7, 67, 'Most popular', <_Perk>[
      _Perk('Everything in Free', true),
      _Perk('Free express shipping', true),
      _Perk('Early sale access', true),
      _Perk('Extended 30-day returns', true),
      _Perk('2× reward points', false),
      _Perk('Personal stylist chat', false),
    ]),
    _Plan('Premium', 14, 134, 'Best value', <_Perk>[
      _Perk('Everything in Plus', true),
      _Perk('2× reward points', true),
      _Perk('Personal stylist chat', true),
      _Perk('Free same-day delivery', true),
      _Perk('Priority support', true),
      _Perk('Exclusive member drops', true),
    ]),
  ];

  bool _yearly = false;
  int _selected = 1;

  @override
  Widget build(BuildContext context) {
    final _Plan plan = _plans[_selected];
    final bool free = plan.monthly == 0;
    final num price = _yearly ? plan.yearly : plan.monthly;

    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, 0, 20, 20),
                  children: <Widget>[
                    _hero(),
                    const SizedBox(height: 18),
                    _billingToggle(),
                    const SizedBox(height: 18),
                    for (int i = 0; i < _plans.length; i++) _planCard(i),
                    const SizedBox(height: 6),
                    _terms(),
                  ],
                ),
              ),
              _bottomBar(plan, free, price),
            ],
          ),
        ),
      ),
    );
  }

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

  Widget _hero() {
    return Column(
      children: const <Widget>[
        CustomPaint(size: Size(72, 72), painter: _CrestPainter()),
        SizedBox(height: 14),
        Text(
          'Unlock member perks',
          textAlign: TextAlign.center,
          style: TextStyle(
            fontFamily: _font,
            fontSize: 22,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.4,
            color: _ink,
          ),
        ),
        SizedBox(height: 6),
        Text(
          'Free express shipping, early access to sales and double points — '
          'cancel anytime.',
          textAlign: TextAlign.center,
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13.5,
            fontWeight: FontWeight.w500,
            height: 1.45,
            color: _muted,
          ),
        ),
      ],
    );
  }

  Widget _billingToggle() {
    return Container(
      height: 46,
      padding: const EdgeInsets.all(4),
      decoration: BoxDecoration(
        color: _surface,
        borderRadius: BorderRadius.circular(9999),
      ),
      child: Row(
        children: <Widget>[
          _toggleHalf('Monthly', !_yearly, () => setState(() => _yearly = false)),
          _toggleHalf('Yearly · save 20%', _yearly,
              () => setState(() => _yearly = true)),
        ],
      ),
    );
  }

  Widget _toggleHalf(String label, bool active, VoidCallback onTap) {
    return Expanded(
      child: GestureDetector(
        onTap: onTap,
        child: Container(
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: active ? _canvas : Colors.transparent,
            borderRadius: BorderRadius.circular(9999),
            boxShadow: active
                ? <BoxShadow>[
                    BoxShadow(
                      color: Colors.black.withValues(alpha: 0.06),
                      blurRadius: 6,
                      offset: const Offset(0, 2),
                    ),
                  ]
                : null,
          ),
          child: Text(
            label,
            style: TextStyle(
              fontFamily: _font,
              fontSize: 13,
              fontWeight: FontWeight.w800,
              color: active ? _ink : _muted,
            ),
          ),
        ),
      ),
    );
  }

  Widget _planCard(int i) {
    final _Plan p = _plans[i];
    final bool selected = _selected == i;
    final bool free = p.monthly == 0;
    final num price = _yearly ? p.yearly : p.monthly;
    return GestureDetector(
      onTap: () => setState(() => _selected = i),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        margin: const EdgeInsets.only(bottom: 12),
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          color: _canvas,
          borderRadius: BorderRadius.circular(18),
          border: Border.all(
            color: selected ? _brand : _hairline,
            width: selected ? 1.8 : 1,
          ),
        ),
        child: Column(
          children: <Widget>[
            Row(
              children: <Widget>[
                _radio(selected),
                const SizedBox(width: 12),
                Text(
                  p.name,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 17,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                const SizedBox(width: 8),
                if (p.tag.isNotEmpty)
                  Flexible(
                    child: Container(
                      padding: const EdgeInsets.symmetric(
                          horizontal: 8, vertical: 3),
                      decoration: BoxDecoration(
                        color: free
                            ? _surface
                            : _brand.withValues(alpha: 0.10),
                        borderRadius: BorderRadius.circular(7),
                      ),
                      child: Text(
                        p.tag,
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 10.5,
                          fontWeight: FontWeight.w800,
                          color: free ? _muted : _brand,
                        ),
                      ),
                    ),
                  ),
                const Spacer(),
                _price(free, price),
              ],
            ),
            const SizedBox(height: 14),
            for (final _Perk perk in p.perks) _perkRow(perk),
          ],
        ),
      ),
    );
  }

  Widget _radio(bool on) {
    return Container(
      width: 22,
      height: 22,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(color: on ? _brand : _faint, width: 2),
        color: on ? _brand : Colors.transparent,
      ),
      child: on
          ? const Icon(Icons.check_rounded, size: 14, color: Colors.white)
          : null,
    );
  }

  Widget _price(bool free, num price) {
    if (free) {
      return const Text(
        'Free',
        style: TextStyle(
          fontFamily: _font,
          fontSize: 16,
          fontWeight: FontWeight.w800,
          color: _ink,
        ),
      );
    }
    return Row(
      crossAxisAlignment: CrossAxisAlignment.end,
      children: <Widget>[
        Text(
          '\$$price',
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 18,
            fontWeight: FontWeight.w800,
            color: _ink,
          ),
        ),
        Text(
          _yearly ? '/yr' : '/mo',
          style: const TextStyle(
            fontFamily: _font,
            fontSize: 12,
            fontWeight: FontWeight.w700,
            color: _muted,
          ),
        ),
      ],
    );
  }

  Widget _perkRow(_Perk perk) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 5),
      child: Row(
        children: <Widget>[
          CustomPaint(
            size: const Size(18, 18),
            painter: _MarkPainter(perk.included),
          ),
          const SizedBox(width: 10),
          Expanded(
            child: Text(
              perk.label,
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w600,
                color: perk.included ? _ink : _faint,
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _terms() {
    return const Padding(
      padding: EdgeInsets.symmetric(horizontal: 4, vertical: 8),
      child: Text(
        'Billed automatically until cancelled. Manage or cancel anytime from '
        'your membership settings. Taxes may apply.',
        textAlign: TextAlign.center,
        style: TextStyle(
          fontFamily: _font,
          fontSize: 11.5,
          fontWeight: FontWeight.w500,
          height: 1.4,
          color: _faint,
        ),
      ),
    );
  }

  Widget _bottomBar(_Plan plan, bool free, num price) {
    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: free ? null : () => widget.onSubscribe?.call(plan.name),
        child: Container(
          height: 56,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: free ? _surface : _brand,
            borderRadius: BorderRadius.circular(9999),
          ),
          child: Text(
            free
                ? 'Your current plan'
                : 'Subscribe to ${plan.name} · \$$price${_yearly ? '/yr' : '/mo'}',
            style: TextStyle(
              fontFamily: _font,
              fontSize: 15.5,
              fontWeight: FontWeight.w800,
              color: free ? _faint : Colors.white,
            ),
          ),
        ),
      ),
    );
  }
}

class _Plan {
  const _Plan(this.name, this.monthly, this.yearly, this.tag, this.perks);
  final String name;
  final int monthly;
  final int yearly;
  final String tag;
  final List<_Perk> perks;
}

class _Perk {
  const _Perk(this.label, this.included);
  final String label;
  final bool included;
}

/// A painted membership crest: a rounded shield with a centred star.
class _CrestPainter extends CustomPainter {
  const _CrestPainter();

  static const Color _brand = Color(0xFFFF385C);

  @override
  void paint(Canvas canvas, Size size) {
    final double w = size.width;
    final double h = size.height;
    final Path shield = Path()
      ..moveTo(w * 0.5, h * 0.04)
      ..lineTo(w * 0.92, h * 0.20)
      ..lineTo(w * 0.92, h * 0.55)
      ..quadraticBezierTo(w * 0.92, h * 0.88, w * 0.5, h * 0.98)
      ..quadraticBezierTo(w * 0.08, h * 0.88, w * 0.08, h * 0.55)
      ..lineTo(w * 0.08, h * 0.20)
      ..close();
    canvas.drawPath(shield, Paint()..color = _brand.withValues(alpha: 0.12));
    canvas.drawPath(
      shield,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2
        ..color = _brand,
    );

    // Centre star.
    final Path star = Path();
    final Offset c = Offset(w * 0.5, h * 0.48);
    final double outer = w * 0.22;
    final double inner = w * 0.10;
    const double pi = 3.141592653589793;
    double rot = -pi / 2;
    for (int i = 0; i < 5; i++) {
      star.lineTo(c.dx + outer * _cos(rot), c.dy + outer * _sin(rot));
      rot += pi / 5;
      star.lineTo(c.dx + inner * _cos(rot), c.dy + inner * _sin(rot));
      rot += pi / 5;
    }
    star.close();
    canvas.drawPath(star, Paint()..color = _brand);
  }

  static double _cos(double x) => _sin(x + 1.5707963267948966);
  static double _sin(double x) {
    x %= 6.283185307179586;
    if (x > 3.141592653589793) x -= 6.283185307179586;
    final double x2 = x * x;
    return x * (1 - x2 / 6 + x2 * x2 / 120 - x2 * x2 * x2 / 5040);
  }

  @override
  bool shouldRepaint(_CrestPainter old) => false;
}

/// A painted benefit mark: brand check disc when included, faint lock dash
/// when not.
class _MarkPainter extends CustomPainter {
  _MarkPainter(this.included);
  final bool included;

  static const Color _brand = Color(0xFFFF385C);
  static const Color _faint = Color(0xFFC1C1C1);

  @override
  void paint(Canvas canvas, Size size) {
    final Offset c = size.center(Offset.zero);
    final double r = size.width / 2;
    if (included) {
      canvas.drawCircle(c, r, Paint()..color = _brand.withValues(alpha: 0.14));
      final Paint tick = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2
        ..strokeCap = StrokeCap.round
        ..strokeJoin = StrokeJoin.round
        ..color = _brand;
      final Path p = Path()
        ..moveTo(size.width * 0.30, size.height * 0.52)
        ..lineTo(size.width * 0.44, size.height * 0.66)
        ..lineTo(size.width * 0.72, size.height * 0.34);
      canvas.drawPath(p, tick);
    } else {
      canvas.drawCircle(c, r,
          Paint()..color = _faint.withValues(alpha: 0.20));
      canvas.drawLine(
        Offset(size.width * 0.32, size.height * 0.5),
        Offset(size.width * 0.68, size.height * 0.5),
        Paint()
          ..strokeWidth = 2
          ..strokeCap = StrokeCap.round
          ..color = _faint,
      );
    }
  }

  @override
  bool shouldRepaint(_MarkPainter old) => old.included != included;
}

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

2. AI agent (MCP)

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

FAQ

Can I ship this membership plans screen in a commercial app?

Yes. It's free for personal and commercial apps, with no licence key and no attribution required. Copy the file from this page, run the CLI command, or pull it through MCP, then swap in your own tiers and prices.

Does it need any packages or assets?

No. The only import is `package:flutter/material.dart`. The crest and the perk marks are `CustomPainter`s, and even the star's trigonometry is written inline, so `dart:math` isn't imported. The one bundled extra is the Manrope font, referenced through the `_font` constant. Leave out the font files and the text falls back to the platform default.

Which Flutter version is required?

Flutter 3.22 or newer, because the toggle shadow, tag pill, crest fill and perk discs all use `Color.withValues(alpha: ...)`. On an older SDK, replace each with `withOpacity(...)`. The constructor also uses `super.key`, which needs Dart 2.17+. Below that, write `{Key? key, this.onBack, this.onSubscribe} : super(key: key)`.

How do I find out whether the shopper picked monthly or yearly?

Right now you can't: `onSubscribe` is a `ValueChanged<String>` that only receives `plan.name`. Change it to `final void Function(String plan, bool yearly)? onSubscribe` and call `widget.onSubscribe?.call(plan.name, _yearly)` in `_bottomBar`. While you're there, load `_plans` from your store's product catalogue instead of the const table. Then work out the toggle's 'save N%' from `1 - yearly / (monthly * 12)` rather than hard-coding 20, so the label can't go stale when prices change.

Why is there a thin coral streak running into the crest?

The star `Path` in `_CrestPainter` starts with `lineTo`, and a path with no `moveTo` begins at (0, 0). The fill therefore includes a sliver from the canvas's top-left corner to the star's first point, which you can see in the preview. To fix it, move to the first outer point before the loop: `star.moveTo(c.dx + outer * _cos(rot), c.dy + outer * _sin(rot))`. Then make the first `lineTo` in the loop a no-op, or start the loop at the inner point.

Related screens