Subscription plan selector
Selectable plan cards with a monthly or yearly switch, savings label and an accessible radio-group structure.
FlutterPricingflutterpricingplanssubscription
Code
import 'package:flutter/material.dart';
class Plan {
const Plan({required this.id, required this.name, required this.monthlyCents, required this.perks});
final String id;
final String name;
final int monthlyCents;
final List<String> perks;
}
class PlanSelector extends StatefulWidget {
const PlanSelector({super.key, required this.plans, required this.onSelected, this.yearlyDiscount = 0.2});
final List<Plan> plans;
final double yearlyDiscount; // 0.2 means two months free-ish
final void Function(Plan plan, bool yearly) onSelected;
@override
State<PlanSelector> createState() => _PlanSelectorState();
}
class _PlanSelectorState extends State<PlanSelector> {
bool _yearly = false;
String? _selected;
String _price(Plan p) {
final monthly = _yearly ? p.monthlyCents * (1 - widget.yearlyDiscount) : p.monthlyCents.toDouble();
return '\$${(monthly / 100).toStringAsFixed(2)} / mo';
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(children: [
SegmentedButton<bool>(
segments: [
const ButtonSegment(value: false, label: Text('Monthly')),
ButtonSegment(value: true, label: Text('Yearly (save ${(widget.yearlyDiscount * 100).round()}%)')),
],
selected: {_yearly},
onSelectionChanged: (s) => setState(() => _yearly = s.first),
),
const SizedBox(height: 12),
RadioGroup<String>(
groupValue: _selected,
onChanged: (id) {
setState(() => _selected = id);
widget.onSelected(widget.plans.firstWhere((p) => p.id == id), _yearly);
},
child: Column(children: [
for (final p in widget.plans)
Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: _selected == p.id ? scheme.primary : scheme.outlineVariant, width: _selected == p.id ? 2 : 1),
),
child: RadioListTile<String>(
value: p.id,
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(_price(p)),
for (final perk in p.perks) Text('• $perk', style: Theme.of(context).textTheme.bodySmall),
]),
),
),
]),
),
]);
}
}
How it works
What it solves
Pricing choices should be easy to compare on a small screen, and the billing period switch must update every price at once. Selection also has to work with keyboards and screen readers, not just touch.
How it works
Plans are plain data holding a monthly price in cents and a list of perks. A SegmentedButton toggles monthly or yearly billing, and the displayed price is derived from the base price and the discount only at render time, so there is no duplicated state.
Selection uses Flutter's RadioGroup ancestor with a RadioListTile per card, giving correct semantics, focus and arrow-key behaviour. The card border thickens and takes the primary colour when selected. The callback reports both the plan and the billing period.
Adapting it
Highlight a recommended plan by adding a flag to Plan and a small badge in the title row. Format prices with intl for other currencies. After selection, enable a Continue button below, and pass the same pair into your checkout or subscription call.
Pitfalls
RadioGrouprequires a recent Flutter release; on older versions usegroupValueandonChangedon eachRadioListTile.- Rounding for yearly prices should match the server's calculation; display only what the server will charge.
- Do not hide the full yearly total, only show the per-month equivalent as a secondary label.
How to use it
Copy to lib/plans/. No packages.
PlanSelector(
plans: const [Plan(id: 'starter', name: 'Starter', monthlyCents: 900, perks: ['2 boxes'])],
onSelected: (plan, yearly) => setState(() => choice = (plan, yearly)),
)
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.