Social86 views

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.

Pulse · Poll Post — Social Flutter UI screen
Live preview — Pulse · Poll Post, built in pure Flutter.

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

1

Create the file

Add a new file at lib/social_feed_poll/social_feed_poll_screen.dart in your Flutter project.

2

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:

pubspec.yaml
flutter:
  fonts:
    - family: Inter
      fonts:
        - asset: fonts/Inter-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.

Two callbacks, six colours and a StatefulWidget

social_feed_poll_screen.dart
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

social_feed_poll_screen.dart
  @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

social_feed_poll_screen.dart
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

social_feed_poll_screen.dart
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

social_feed_poll_screen.dart
          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

social_feed_poll_screen.dart
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

social_feed_poll_screen.dart
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-poll

2. 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)`.

Related screens