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.

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
Create the file
Add a new file at lib/ecom_membership_active/ecom_membership_active_screen.dart in your Flutter project.
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:
flutter:
fonts:
- family: Manrope
fonts:
- asset: fonts/Manrope-Regular.ttfBuild 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
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
@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
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
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
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
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
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
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-active2. 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.