How to Build a Social Poll Post Card in Flutter (Full Code + Preview)
A poll in a feed has to do two things at once: look inviting before anyone taps, and turn into a readable result chart the instant they do. This tutorial builds the Pulse poll post in Flutter: a single `_choice` int drives the whole card, `_OptionRow` swaps from an indigo outline pill to a `FractionallySizedBox` result bar with a percentage and a `check_circle` on your pick, `_votesLabel` abbreviates 1204 to 1.2k, and a second, already-ended poll shows the final-results state with taps disabled.

Watch the Flutter UI walkthrough
A short screen recording of Pulse · Poll Post running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.
Can't see the video? Watch it on YouTube.
What you'll build
- ✓A `_PollCard` that re-renders every option from one nullable `_choice` int, with `onVote(int)` injected so real submission is a one-line wire-up
- ✓A two-state `_OptionRow`: a `_surfaceAlt` outline pill before voting and a `ClipRRect` + `FractionallySizedBox` result bar tinted to the option's percentage afterwards
- ✓A `_votesLabel` getter that turns 1204 into '1.2k votes', and an `_initials` getter that builds the monogram from the author's name
- ✓An ended-poll variant that loads already resolved, passes `onTap: null` to every row, and reads 'Final results · ended'
- ✓A gradient `_Monogram` avatar, a `_Dot` separator for the meta line, and an `_ActionBar` with like, reply, share and bookmark
Step-by-step build
Create the file
Add a new file at lib/social_feed_poll/social_feed_poll_screen.dart in your Flutter project.
Register the bundled fonts
No external packages — this is pure Flutter. It does bundle its design font (Inter), so drop the font file into fonts/ and declare it in pubspec.yaml:
flutter:
fonts:
- family: Inter
fonts:
- asset: fonts/Inter-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.
Two callbacks, six colours and a StatefulWidget
import 'package:flutter/material.dart';
/// Poll Post — an inline poll card with painted result bars. The hero poll is
/// interactive: tap an option to cast a vote and the outline rows switch to
/// tinted result bars with percentages and your choice highlighted. A second,
/// already-ended poll shows the final-results state. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, own dark theme, SafeArea,
/// overflow-proof.
class SocialFeedPollScreen extends StatefulWidget {
const SocialFeedPollScreen({
super.key,
this.onBack,
this.onVote,
});
final VoidCallback? onBack;
final ValueChanged<int>? onVote;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0B0F);
static const Color _surfaceAlt = Color(0xFF1D1D26);
static const Color _brand = Color(0xFF6E56F7);
static const Color _hairline = Color(0xFF26262F);
static const Color _textHi = Color(0xFFF4F4F7);
static const Color _muted = Color(0xFF8A8A99);
@override
State<SocialFeedPollScreen> createState() => _SocialFeedPollScreenState();
}
class _SocialFeedPollScreenState extends State<SocialFeedPollScreen> {
int? _choice;
static const List<_Option> _live = <_Option>[
_Option('Faster search', 46),
_Option('Threaded replies', 29),
_Option('Custom themes', 15),
_Option('Scheduled posts', 10),
];
static const List<_Option> _ended = <_Option>[
_Option('Weekly digest', 58),
_Option('Daily digest', 27),
_Option('No digest', 15),
];`SocialFeedPollScreen` is a `StatefulWidget` because one thing on this page changes: which option the reader picked. It takes `onBack` and `onVote` (a `ValueChanged<int>`) so the host app hears the chosen index without the screen knowing anything about a backend. The palette is a near-mono dark set — `_bg` #0B0B0F, `_surfaceAlt` #1D1D26, `_hairline` #26262F — with a single indigo `_brand` #6E56F7 used for everything that means 'you' or 'act here'. State is just `int? _choice`; null means unvoted. The two option lists are `static const` `_Option(label, pct)` records. `_live` sums to exactly 100 (46+29+15+10) and so does `_ended` (58+27+15), which matters because the percentages are what size the result bars later.
The feed: one live poll, one ended poll
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: SocialFeedPollScreen._bg,
body: SafeArea(
child: Column(
children: <Widget>[
_TopBar(onBack: widget.onBack),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: <Widget>[
_PollCard(
name: 'Maya Chen',
handle: 'mayabuilds',
time: '3h',
colorA: 0xFF6E56F7,
colorB: 0xFF9B8CFF,
question: 'Which should ship first in v2?',
options: _live,
choice: _choice,
voted: _choice != null,
totalVotes: 1204,
footer: '1 day left',
ended: false,
onVote: (int i) {
setState(() => _choice = i);
widget.onVote?.call(i);
},
),
_PollCard(
name: 'Dev Kapoor',
handle: 'devk',
time: '2d',
colorA: 0xFF34D399,
colorB: 0xFF6E56F7,
question: 'How often do you want the Pulse digest?',
options: _ended,
choice: 0,
voted: true,
totalVotes: 3820,
footer: 'Final results · ended',
ended: true,
onVote: (_) {},
),
const SizedBox(height: 24),
],
),
),
],
),
),
),
);
}
}`build` wraps everything in `Theme(data: ThemeData.dark(useMaterial3: true))` so Material defaults like the `IconButton` ripple match the dark surface without leaking the host app's light theme. Under `SafeArea`, a `Column` holds the `_TopBar` and an `Expanded` `ListView` with `padding: EdgeInsets.zero` so the first card's hairline sits flush against the bar. The interesting part is how the two `_PollCard`s differ only by props. The live card passes `choice: _choice`, `voted: _choice != null`, `ended: false`, and an `onVote` that calls `setState(() => _choice = i)` then forwards to `widget.onVote?.call(i)`. The ended card hard-codes `choice: 0`, `voted: true`, `ended: true`, and an `onVote: (_) {}` no-op — it renders as final results without any state of its own. The gradient pairs (`0xFF6E56F7`→`0xFF9B8CFF` and `0xFF34D399`→`0xFF6E56F7`) give each author a distinct monogram.
A 56px top bar and the _Option record
class _TopBar extends StatelessWidget {
const _TopBar({this.onBack});
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
return Container(
height: 56,
padding: const EdgeInsets.only(left: 4, right: 20),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: SocialFeedPollScreen._hairline),
),
),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new,
size: 18, color: SocialFeedPollScreen._textHi),
),
const Text(
'Poll',
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: SocialFeedPollScreen._textHi,
),
),
],
),
);
}
}
class _Option {
const _Option(this.label, this.pct);
final String label;
final int pct;
}`_TopBar` is a fixed 56px `Container` with a bottom `BorderSide` in `_hairline` — the same separator every card uses, so the bar reads as the first row of the feed rather than a distinct chrome layer. Padding is `left: 4, right: 20`: the small left inset is deliberate because the `IconButton` carries its own 48px hit area, and 4px places the `arrow_back_ios_new` glyph optically where a 16px margin would put plain text. The title 'Poll' is 18px `w700` Inter with `letterSpacing: -0.4`, the same tightening used on the questions further down. `_Option` is a two-field `const` class — `label` and an `int pct` — kept private and minimal so both lists can be `static const` and the whole card tree stays const-constructible where possible.
The poll card header and its two getters
class _PollCard extends StatelessWidget {
const _PollCard({
required this.name,
required this.handle,
required this.time,
required this.colorA,
required this.colorB,
required this.question,
required this.options,
required this.choice,
required this.voted,
required this.totalVotes,
required this.footer,
required this.ended,
required this.onVote,
});
final String name;
final String handle;
final String time;
final int colorA;
final int colorB;
final String question;
final List<_Option> options;
final int? choice;
final bool voted;
final int totalVotes;
final String footer;
final bool ended;
final ValueChanged<int> onVote;
String get _initials {
final List<String> p = name.trim().split(RegExp(r'\s+'));
if (p.length == 1) return p.first.characters.first.toUpperCase();
return (p.first[0] + p.last[0]).toUpperCase();
}
String get _votesLabel {
final String v = totalVotes >= 1000
? '${(totalVotes / 1000).toStringAsFixed(1)}k'
: '$totalVotes';
return '$v votes';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 12, 12),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: SocialFeedPollScreen._hairline),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_Monogram(
initials: _initials,
colorA: Color(colorA),
colorB: Color(colorB),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Flexible(
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 15,
fontWeight: FontWeight.w700,
color: SocialFeedPollScreen._textHi,
),
),
),
const SizedBox(width: 6),
const Icon(Icons.verified,
size: 15, color: SocialFeedPollScreen._brand),
],
),
Text(
'@$handle · $time',
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
color: SocialFeedPollScreen._muted,
),
),
],
),
),
const Icon(Icons.more_horiz,
color: SocialFeedPollScreen._muted, size: 22),
const SizedBox(width: 6),
],
),`_PollCard` is a `StatelessWidget` with thirteen required props; it holds no state, so the parent's `_choice` is the single source of truth. Two getters do the string work. `_initials` splits `name` on `RegExp(r'\s+')`, returns the first character upper-cased for a single word, or first-of-first plus first-of-last for 'Maya Chen' → 'MC'. `_votesLabel` divides by 1000 with `toStringAsFixed(1)` only when `totalVotes >= 1000`, so 1204 becomes '1.2k votes' while 820 would stay '820 votes'. The header row puts the `_Monogram` beside an `Expanded` column: the name is in a `Flexible` with `TextOverflow.ellipsis` so a long name truncates instead of pushing the 15px indigo `Icons.verified` off-screen, and the second line concatenates `'@$handle · $time'` in 12.5px `_muted`. `Icons.more_horiz` closes the row with a 6px trailing gap to align with the card's 12px right padding.
Question, option loop, and the vote meta line
const SizedBox(height: 12),
Text(
question,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: SocialFeedPollScreen._textHi,
),
),
const SizedBox(height: 14),
for (int i = 0; i < options.length; i++) ...<Widget>[
_OptionRow(
option: options[i],
voted: voted,
chosen: choice == i,
onTap: ended ? null : () => onVote(i),
),
if (i != options.length - 1) const SizedBox(height: 10),
],
const SizedBox(height: 14),
Row(
children: <Widget>[
Text(
_votesLabel,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
color: SocialFeedPollScreen._muted,
),
),
const _Dot(),
Flexible(
child: Text(
footer,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
color: SocialFeedPollScreen._muted,
),
),
),
if (voted && !ended) ...<Widget>[
const _Dot(),
const Text(
'You voted',
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: SocialFeedPollScreen._brand,
),
),
],
],
),
const SizedBox(height: 10),
const _ActionBar(),
],
),
);
}
}The question is 16px `w700` with `letterSpacing: -0.3`. The options come from a collection-for that spreads two widgets per iteration — the `_OptionRow` and, guarded by `if (i != options.length - 1)`, a 10px `SizedBox` — so the gap never trails the last row. Each row receives `chosen: choice == i` and `onTap: ended ? null : () => onVote(i)`; passing null rather than an empty closure is what makes the ended poll genuinely inert instead of silently swallowing taps. The meta line is a `Row` of `_votesLabel`, a `_Dot`, the `footer` in a `Flexible` with ellipsis, and then — only when `voted && !ended` — another `_Dot` plus 'You voted' in `w600` indigo. The ended poll skips that tag because 'Final results · ended' already says the poll is closed. A 10px gap then hands over to the `const _ActionBar()`.
_Dot and the two faces of _OptionRow
class _Dot extends StatelessWidget {
const _Dot();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 7),
child: Text(
'·',
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 13,
color: SocialFeedPollScreen._muted,
),
),
);
}
}
class _OptionRow extends StatelessWidget {
const _OptionRow({
required this.option,
required this.voted,
required this.chosen,
this.onTap,
});
final _Option option;
final bool voted;
final bool chosen;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
if (!voted) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: SocialFeedPollScreen._surfaceAlt,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: SocialFeedPollScreen._hairline),
),
child: Text(
option.label,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: SocialFeedPollScreen._brand,
),
),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
height: 46,
decoration: BoxDecoration(
color: SocialFeedPollScreen._surfaceAlt.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: chosen
? SocialFeedPollScreen._brand
: SocialFeedPollScreen._hairline,
),
),
child: Stack(
children: <Widget>[
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: (option.pct / 100).clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: chosen
? SocialFeedPollScreen._brand.withValues(alpha: 0.30)
: SocialFeedPollScreen._brand.withValues(alpha: 0.12),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: <Widget>[
if (chosen) ...<Widget>[
const Icon(Icons.check_circle,
size: 17, color: SocialFeedPollScreen._brand),
const SizedBox(width: 8),
],
Expanded(
child: Text(
option.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 14.5,
fontWeight: chosen ? FontWeight.w700 : FontWeight.w500,
color: SocialFeedPollScreen._textHi,
),
),
),
Text(
'${option.pct}%',
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 14,
fontWeight: FontWeight.w700,
color: SocialFeedPollScreen._textHi,
),
),
],
),
),
],
),
),
);
}
}`_Dot` is a middle-dot glyph padded 7px each side, so the meta line separators are typographic rather than drawn. `_OptionRow` branches on `voted`. Before a vote it is a `GestureDetector` around a 46px `_surfaceAlt` container with a 12px radius, a `_hairline` border and the label centred in 14.5px `w600` indigo — the brand colour on the text is the affordance. After a vote the row becomes a `ClipRRect` (needed so the fill respects the rounded corners) around a `Stack`. The bottom layer is a `FractionallySizedBox` aligned `centerLeft` with `widthFactor: (option.pct / 100).clamp(0.0, 1.0)` — the bar's width literally is the percentage. Its fill is `_brand` at alpha 0.30 for the chosen row and 0.12 for the rest, over a card background at `_surfaceAlt.withValues(alpha: 0.6)`. The chosen row also gets a `_brand` border, a 17px `check_circle`, and `w700` text; the others keep the `_hairline` border and `w500`. The percentage sits right-aligned in 14px `w700` outside the `Expanded` label so it never truncates.
Action bar, _Action, and the gradient monogram
class _ActionBar extends StatelessWidget {
const _ActionBar();
@override
Widget build(BuildContext context) {
return const Row(
children: <Widget>[
_Action(icon: Icons.favorite_border, label: '312'),
SizedBox(width: 18),
_Action(icon: Icons.chat_bubble_outline, label: '54'),
SizedBox(width: 18),
_Action(icon: Icons.share_outlined, label: 'Share'),
Spacer(),
Icon(Icons.bookmark_border,
size: 21, color: SocialFeedPollScreen._muted),
SizedBox(width: 8),
],
);
}
}
class _Action extends StatelessWidget {
const _Action({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Icon(icon, size: 20, color: SocialFeedPollScreen._muted),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 13,
fontWeight: FontWeight.w500,
color: SocialFeedPollScreen._muted,
),
),
],
);
}
}
class _Monogram extends StatelessWidget {
const _Monogram({
required this.initials,
required this.colorA,
required this.colorB,
});
final String initials;
final Color colorA;
final Color colorB;
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[colorA, colorB],
),
),
child: Center(
child: Text(
initials,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
);
}
}`_ActionBar` is a fully `const` `Row`: three `_Action`s (favorite_border '312', chat_bubble_outline '54', share_outlined 'Share') separated by 18px, a `Spacer`, then a 21px `bookmark_border` with an 8px trailing gap. Being const means the bar is built once and reused by both cards for free. `_Action` pairs a 20px `_muted` icon with a 13px `w500` label 6px apart, and every glyph stays `_muted` so nothing here competes with the indigo of the poll itself. `_Monogram` is a 42px circle whose `BoxDecoration` uses a `LinearGradient` from `topLeft` to `bottomRight` between the two ints the card passed in as `colorA`/`colorB`, with the initials centred in 15px `w700` white. Because the avatar is painted from the name, the screen loads no network image and can never show a broken-image placeholder.
Full code
The complete, ready-to-paste source. Free to use in your projects — one click copies it all.
import 'package:flutter/material.dart';
/// Poll Post — an inline poll card with painted result bars. The hero poll is
/// interactive: tap an option to cast a vote and the outline rows switch to
/// tinted result bars with percentages and your choice highlighted. A second,
/// already-ended poll shows the final-results state. Self-contained per
/// CONVENTIONS.md: pure Flutter, bundled Inter font, own dark theme, SafeArea,
/// overflow-proof.
class SocialFeedPollScreen extends StatefulWidget {
const SocialFeedPollScreen({
super.key,
this.onBack,
this.onVote,
});
final VoidCallback? onBack;
final ValueChanged<int>? onVote;
static const String _font = 'Inter';
static const Color _bg = Color(0xFF0B0B0F);
static const Color _surfaceAlt = Color(0xFF1D1D26);
static const Color _brand = Color(0xFF6E56F7);
static const Color _hairline = Color(0xFF26262F);
static const Color _textHi = Color(0xFFF4F4F7);
static const Color _muted = Color(0xFF8A8A99);
@override
State<SocialFeedPollScreen> createState() => _SocialFeedPollScreenState();
}
class _SocialFeedPollScreenState extends State<SocialFeedPollScreen> {
int? _choice;
static const List<_Option> _live = <_Option>[
_Option('Faster search', 46),
_Option('Threaded replies', 29),
_Option('Custom themes', 15),
_Option('Scheduled posts', 10),
];
static const List<_Option> _ended = <_Option>[
_Option('Weekly digest', 58),
_Option('Daily digest', 27),
_Option('No digest', 15),
];
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: Scaffold(
backgroundColor: SocialFeedPollScreen._bg,
body: SafeArea(
child: Column(
children: <Widget>[
_TopBar(onBack: widget.onBack),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: <Widget>[
_PollCard(
name: 'Maya Chen',
handle: 'mayabuilds',
time: '3h',
colorA: 0xFF6E56F7,
colorB: 0xFF9B8CFF,
question: 'Which should ship first in v2?',
options: _live,
choice: _choice,
voted: _choice != null,
totalVotes: 1204,
footer: '1 day left',
ended: false,
onVote: (int i) {
setState(() => _choice = i);
widget.onVote?.call(i);
},
),
_PollCard(
name: 'Dev Kapoor',
handle: 'devk',
time: '2d',
colorA: 0xFF34D399,
colorB: 0xFF6E56F7,
question: 'How often do you want the Pulse digest?',
options: _ended,
choice: 0,
voted: true,
totalVotes: 3820,
footer: 'Final results · ended',
ended: true,
onVote: (_) {},
),
const SizedBox(height: 24),
],
),
),
],
),
),
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({this.onBack});
final VoidCallback? onBack;
@override
Widget build(BuildContext context) {
return Container(
height: 56,
padding: const EdgeInsets.only(left: 4, right: 20),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: SocialFeedPollScreen._hairline),
),
),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new,
size: 18, color: SocialFeedPollScreen._textHi),
),
const Text(
'Poll',
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
color: SocialFeedPollScreen._textHi,
),
),
],
),
);
}
}
class _Option {
const _Option(this.label, this.pct);
final String label;
final int pct;
}
class _PollCard extends StatelessWidget {
const _PollCard({
required this.name,
required this.handle,
required this.time,
required this.colorA,
required this.colorB,
required this.question,
required this.options,
required this.choice,
required this.voted,
required this.totalVotes,
required this.footer,
required this.ended,
required this.onVote,
});
final String name;
final String handle;
final String time;
final int colorA;
final int colorB;
final String question;
final List<_Option> options;
final int? choice;
final bool voted;
final int totalVotes;
final String footer;
final bool ended;
final ValueChanged<int> onVote;
String get _initials {
final List<String> p = name.trim().split(RegExp(r'\s+'));
if (p.length == 1) return p.first.characters.first.toUpperCase();
return (p.first[0] + p.last[0]).toUpperCase();
}
String get _votesLabel {
final String v = totalVotes >= 1000
? '${(totalVotes / 1000).toStringAsFixed(1)}k'
: '$totalVotes';
return '$v votes';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 12, 12),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: SocialFeedPollScreen._hairline),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_Monogram(
initials: _initials,
colorA: Color(colorA),
colorB: Color(colorB),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Flexible(
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 15,
fontWeight: FontWeight.w700,
color: SocialFeedPollScreen._textHi,
),
),
),
const SizedBox(width: 6),
const Icon(Icons.verified,
size: 15, color: SocialFeedPollScreen._brand),
],
),
Text(
'@$handle · $time',
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
color: SocialFeedPollScreen._muted,
),
),
],
),
),
const Icon(Icons.more_horiz,
color: SocialFeedPollScreen._muted, size: 22),
const SizedBox(width: 6),
],
),
const SizedBox(height: 12),
Text(
question,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: SocialFeedPollScreen._textHi,
),
),
const SizedBox(height: 14),
for (int i = 0; i < options.length; i++) ...<Widget>[
_OptionRow(
option: options[i],
voted: voted,
chosen: choice == i,
onTap: ended ? null : () => onVote(i),
),
if (i != options.length - 1) const SizedBox(height: 10),
],
const SizedBox(height: 14),
Row(
children: <Widget>[
Text(
_votesLabel,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
color: SocialFeedPollScreen._muted,
),
),
const _Dot(),
Flexible(
child: Text(
footer,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
color: SocialFeedPollScreen._muted,
),
),
),
if (voted && !ended) ...<Widget>[
const _Dot(),
const Text(
'You voted',
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: SocialFeedPollScreen._brand,
),
),
],
],
),
const SizedBox(height: 10),
const _ActionBar(),
],
),
);
}
}
class _Dot extends StatelessWidget {
const _Dot();
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 7),
child: Text(
'·',
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 13,
color: SocialFeedPollScreen._muted,
),
),
);
}
}
class _OptionRow extends StatelessWidget {
const _OptionRow({
required this.option,
required this.voted,
required this.chosen,
this.onTap,
});
final _Option option;
final bool voted;
final bool chosen;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
if (!voted) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 46,
alignment: Alignment.center,
decoration: BoxDecoration(
color: SocialFeedPollScreen._surfaceAlt,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: SocialFeedPollScreen._hairline),
),
child: Text(
option.label,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: SocialFeedPollScreen._brand,
),
),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
height: 46,
decoration: BoxDecoration(
color: SocialFeedPollScreen._surfaceAlt.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: chosen
? SocialFeedPollScreen._brand
: SocialFeedPollScreen._hairline,
),
),
child: Stack(
children: <Widget>[
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: (option.pct / 100).clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: chosen
? SocialFeedPollScreen._brand.withValues(alpha: 0.30)
: SocialFeedPollScreen._brand.withValues(alpha: 0.12),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: <Widget>[
if (chosen) ...<Widget>[
const Icon(Icons.check_circle,
size: 17, color: SocialFeedPollScreen._brand),
const SizedBox(width: 8),
],
Expanded(
child: Text(
option.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 14.5,
fontWeight: chosen ? FontWeight.w700 : FontWeight.w500,
color: SocialFeedPollScreen._textHi,
),
),
),
Text(
'${option.pct}%',
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 14,
fontWeight: FontWeight.w700,
color: SocialFeedPollScreen._textHi,
),
),
],
),
),
],
),
),
);
}
}
class _ActionBar extends StatelessWidget {
const _ActionBar();
@override
Widget build(BuildContext context) {
return const Row(
children: <Widget>[
_Action(icon: Icons.favorite_border, label: '312'),
SizedBox(width: 18),
_Action(icon: Icons.chat_bubble_outline, label: '54'),
SizedBox(width: 18),
_Action(icon: Icons.share_outlined, label: 'Share'),
Spacer(),
Icon(Icons.bookmark_border,
size: 21, color: SocialFeedPollScreen._muted),
SizedBox(width: 8),
],
);
}
}
class _Action extends StatelessWidget {
const _Action({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Icon(icon, size: 20, color: SocialFeedPollScreen._muted),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 13,
fontWeight: FontWeight.w500,
color: SocialFeedPollScreen._muted,
),
),
],
);
}
}
class _Monogram extends StatelessWidget {
const _Monogram({
required this.initials,
required this.colorA,
required this.colorB,
});
final String initials;
final Color colorA;
final Color colorB;
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[colorA, colorB],
),
),
child: Center(
child: Text(
initials,
style: const TextStyle(
fontFamily: SocialFeedPollScreen._font,
fontSize: 15,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
);
}
}
Plus bundled 1 binary asset (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 social-feed-poll2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install social-feed-poll — it fetches and writes the files for you.
FAQ
Can I use this poll card in a commercial app?
Yes. FlutterKit screens are free for personal and commercial projects under an MIT-style licence — no key, no attribution line. Copy the code from this page or run `flutterkit add social-feed-poll` and ship it.
Does it need any pub packages or fonts?
No packages — it is pure Flutter with `material.dart` only. The Inter font is referenced by name and is bundled into your project by `flutterkit add social-feed-poll`; if you paste the code by hand, add Inter to `pubspec.yaml` or drop the `fontFamily` lines to fall back to the platform font.
How do I make the percentages come from a server?
Replace the two `static const` lists with data you fetch, keep `_Option(label, pct)` as the shape, and set `totalVotes` from the response. The result bars size themselves from `option.pct / 100`, so as long as the server returns integers that sum to about 100 nothing else changes. Submit the vote inside `onVote` — the screen already forwards the tapped index to `widget.onVote` — and refresh the list when the response arrives.
Why does the ended poll pass `onTap: null` instead of an empty function?
`_PollCard` computes `onTap: ended ? null : () => onVote(i)`. A null callback tells `GestureDetector` there is nothing to listen for, so the row is genuinely inert; an empty closure would still register taps and give feedback that does nothing. In practice the ended card is already in `voted: true` mode, so the outline branch never renders for it anyway.
Which Flutter version is required?
Flutter 3.22 or newer, because the result bars use `Color.withValues(alpha: ...)` and the widgets use `super.key`. On an older SDK swap `withValues(alpha: x)` for `withOpacity(x)` and write the constructor as `{Key? key, ...} : super(key: key)`.