E-commerce72 views

How to Build a Product Questions and Answers Screen in Flutter (Full Code + Preview)

Shoppers ask the questions a product description never answers — fit, fabric weight, whether the buttons are real horn. This tutorial builds StyleCart's buyer Q&A screen in Flutter: a searchable, divided list where every question carries a tappable upvote arrow with a live count, the asker's name and date, and one best answer boxed in a grey panel. Answers are attributed with a storefront or person icon and a brand Seller pill when the shop replied, and a pinned composer bar lets anyone ask their own.

Questions & Answers — E-commerce Flutter UI screen
Live preview — Questions & Answers, built in pure Flutter.

Watch the Flutter UI walkthrough

A short screen recording of Questions & Answers 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 `_Qa` model that pairs one question with its single best answer, an answerer and a mutable vote count
  • An upvote arrow and count that recolour to brand coral together, backed by a `Set<int>` of voted indexes
  • Answer attribution that switches icon, colour and a `Seller` pill depending on who replied
  • A rounded 'Search questions' field above a `ListView.separated` divided by hairlines
  • A pinned composer with a 48px rounded input and a circular coral send button that hands the text to `onAsk`

Step-by-step build

1

Create the file

Add a new file at lib/ecom_product_qna/ecom_product_qna_screen.dart in your Flutter project.

2

Register the bundled fonts

No external packages — this is pure Flutter. It does bundle its design font (Manrope), so drop the font file into fonts/ and declare it in pubspec.yaml:

pubspec.yaml
flutter:
  fonts:
    - family: Manrope
      fonts:
        - asset: fonts/Manrope-Regular.ttf
3

Build it, piece by piece

Here's how the screen goes together. Each block below is a real slice of the code with a plain-English explanation — paste them in order, or grab the whole file from the next section.

A stateful screen with two controllers and a flat palette

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

/// StyleCart — Questions & Answers.
///
/// Buyer Q&A for a product: a search field, a list of questions each with its
/// best answer (tagged seller or shopper), an upvote toggle and an answer count,
/// plus a pinned "ask a question" composer bar.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, no images (icons only).
/// Exposes callbacks only; the registry wires navigation.
class EcomProductQnaScreen extends StatefulWidget {
  const EcomProductQnaScreen({
    super.key,
    this.onBack,
    this.onAsk,
  });

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

  @override
  State<EcomProductQnaScreen> createState() => _EcomProductQnaScreenState();
}

class _EcomProductQnaScreenState extends State<EcomProductQnaScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);

  final TextEditingController _ask = TextEditingController();
  final TextEditingController _search = TextEditingController();

`EcomProductQnaScreen` is a `StatefulWidget` because votes change locally, and it exposes only `onBack` and `onAsk` — `ValueChanged<String>` on the latter, so the host receives whatever the shopper typed rather than the widget deciding what to do with it. The palette is seven `static const Color` fields on the state: `_ink` `#222222` for question text, `_muted` `#6A6A6A` for metadata, `_faint` `#C1C1C1` for the un-voted arrow and hints, `_brand` `#FF385C` for anything the reader has acted on, `_surface` `#F2F2F2` for the answer panels and inputs, `_hairline` `#EBEBEB` for dividers. Two separate `TextEditingController`s exist because searching and asking are different jobs.

The Q&A model list, the voted set, and the toggle

ecom_product_qna_screen.dart
  final List<_Qa> _items = <_Qa>[
    _Qa(
      'Does this run true to size?',
      'Asha · Jun 9',
      'It runs slightly boxy by design. If you prefer a trimmer fit, size down one — I usually wear M and took an S.',
      'StyleCart',
      true,
      24,
    ),
    _Qa(
      'Is the cotton heavy enough for autumn layering?',
      'Tom · Jun 5',
      'Mid-weight twill — great as an overshirt over a tee, and works under a coat once it gets cold.',
      'Daniel K.',
      false,
      11,
    ),
    _Qa(
      'Will the olive colour fade after washing?',
      'Priya · May 30',
      'Garment-dyed, so it softens a touch on the first wash then holds. Wash cold inside-out to keep it rich.',
      'StyleCart',
      true,
      9,
    ),
    _Qa(
      'Are the buttons real horn?',
      'Marco · May 24',
      'They\'re corozo (tagua nut) — natural and hard-wearing, very close to horn.',
      'StyleCart',
      true,
      6,
    ),
  ];

  final Set<int> _voted = <int>{};

  @override
  void dispose() {
    _ask.dispose();
    _search.dispose();
    super.dispose();
  }

  void _toggle(int i) {
    setState(() {
      if (_voted.contains(i)) {
        _voted.remove(i);
        _items[i].votes--;
      } else {
        _voted.add(i);
        _items[i].votes++;
      }
    });
  }

Four `_Qa` records seed the list, each holding a question, an `asker` string that packs name and date into one line ('Asha · Jun 9'), a single best answer, the answerer, a `bySeller` flag and a starting vote count. Only the best answer is stored — no reply thread — which is what keeps a shopping Q&A skimmable. Vote state is deliberately split: `_voted` is a `Set<int>` of indexes that says whether *you* voted, while `q.votes` is the public tally. `_toggle` mutates both inside `setState`, incrementing or decrementing so the number moves with the arrow, and `dispose` releases both controllers.

A column that pins the composer and scrolls only the list

ecom_product_qna_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              _searchBar(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView.separated(
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  itemCount: _items.length,
                  separatorBuilder: (_, _) =>
                      const Divider(height: 1, color: _hairline),
                  itemBuilder: (BuildContext context, int i) =>
                      _card(_items[i], i),
                ),
              ),
              _composer(),
            ],
          ),
        ),
      ),
    );
  }

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the screen renders identically inside a dark host app. Inside `SafeArea` a `Column` stacks header, search bar, a 1px `Divider`, an `Expanded` list and the composer — the `Expanded` is what makes only the questions scroll while the search field stays reachable and the ask bar stays pinned to the bottom. `ListView.separated` draws a `_hairline` divider between cards rather than boxing each one, which suits a text-heavy list; its separator uses Dart's `(_, _)` wildcard parameters, since neither the context nor the index is needed.

A header that is a back arrow plus a title

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

The header is a plain `Row` in a `Padding` of `fromLTRB(8, 4, 20, 4)` — the left inset drops to 8 because `IconButton` carries its own 48px touch target, so a full 20 would push the arrow visibly off-grid against the 20px list padding below. `Icons.arrow_back_ios_new_rounded` at 20px signals a push route rather than a modal dismissal, since Q&A is opened from a product page. The title 'Questions & answers' sits in an `Expanded` at 19px `w800` with `letterSpacing: -0.3`; tightening the tracking on a heavy weight stops the two words from looking loose at that size.

The search field as a filled pill, not a bordered input

ecom_product_qna_screen.dart
  Widget _searchBar() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
      child: Container(
        height: 46,
        padding: const EdgeInsets.symmetric(horizontal: 14),
        decoration: BoxDecoration(
          color: _surface,
          borderRadius: BorderRadius.circular(14),
        ),
        child: Row(
          children: <Widget>[
            const Icon(Icons.search_rounded, size: 20, color: _muted),
            const SizedBox(width: 10),
            Expanded(
              child: TextField(
                controller: _search,
                cursorColor: _brand,
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 14.5,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
                decoration: const InputDecoration(
                  isDense: true,
                  border: InputBorder.none,
                  hintText: 'Search questions',
                  hintStyle: TextStyle(
                    fontFamily: _font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w500,
                    color: _faint,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

The search bar is a 46px `Container` filled `_surface` with `BorderRadius.circular(14)`, holding a 20px `Icons.search_rounded` in `_muted`, a 10px gap and an `Expanded` `TextField`. The `TextField` sets `border: InputBorder.none` and `isDense: true` so Material's own underline and generous vertical padding do not fight the container — the box supplies the shape, the field supplies only text. `cursorColor: _brand` is the one coral touch. Typed text is 14.5px `w600` while the 'Search questions' hint is `w500` in `_faint`, so a real query reads heavier than the placeholder it replaced.

The vote column, the Q marker and the asker line

ecom_product_qna_screen.dart
  Widget _card(_Qa q, int i) {
    final bool voted = _voted.contains(i);
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 16),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          GestureDetector(
            onTap: () => _toggle(i),
            child: Column(
              children: <Widget>[
                Icon(
                  Icons.keyboard_arrow_up_rounded,
                  size: 24,
                  color: voted ? _brand : _faint,
                ),
                Text(
                  '${q.votes}',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 13,
                    fontWeight: FontWeight.w800,
                    color: voted ? _brand : _muted,
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    const Padding(
                      padding: EdgeInsets.only(top: 1),
                      child: Text(
                        'Q',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 14,
                          fontWeight: FontWeight.w800,
                          color: _brand,
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: Text(
                        q.question,
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 15,
                          fontWeight: FontWeight.w700,
                          height: 1.3,
                          color: _ink,
                        ),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 3),
                Padding(
                  padding: const EdgeInsets.only(left: 22),
                  child: Text(
                    q.asker,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ),

Each card is a `Row` with `crossAxisAlignment: CrossAxisAlignment.start`, so the vote control stays beside the first line of a question that wraps. The `GestureDetector` wraps a whole `Column` — 24px `Icons.keyboard_arrow_up_rounded` above a 13px `w800` count — so arrow and number are one target, and both read `voted ? _brand : ...` so they flip to coral together. The question is prefixed by a coral 'Q' nudged down 1px to align optically with 15px text, and the `asker` line beneath is indented `left: 22` to clear that marker, keeping 'Asha · Jun 9' hanging under the question rather than the letter.

The answer panel and its seller badge

ecom_product_qna_screen.dart
                const SizedBox(height: 12),
                Container(
                  padding: const EdgeInsets.all(13),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(
                        q.answer,
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 13.5,
                          fontWeight: FontWeight.w500,
                          height: 1.45,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(height: 8),
                      Row(
                        children: <Widget>[
                          Icon(
                            q.bySeller
                                ? Icons.storefront_rounded
                                : Icons.person_rounded,
                            size: 14,
                            color: q.bySeller ? _brand : _muted,
                          ),
                          const SizedBox(width: 5),
                          Text(
                            q.answerer,
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              fontWeight: FontWeight.w700,
                              color: q.bySeller ? _brand : _ink,
                            ),
                          ),
                          if (q.bySeller) ...<Widget>[
                            const SizedBox(width: 6),
                            Container(
                              padding: const EdgeInsets.symmetric(
                                  horizontal: 6, vertical: 1),
                              decoration: BoxDecoration(
                                color: _brand.withValues(alpha: 0.12),
                                borderRadius: BorderRadius.circular(5),
                              ),
                              child: const Text(
                                'Seller',
                                style: TextStyle(
                                  fontFamily: _font,
                                  fontSize: 10,
                                  fontWeight: FontWeight.w800,
                                  color: _brand,
                                ),
                              ),
                            ),
                          ],
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

The answer sits in a `_surface` container with 13px padding and a 12px radius — a tinted block is what visually separates a reply from the question without a second divider. Body text is 13.5px `w500` at `height: 1.45` for comfortable multi-line reading. The attribution row is where trust is built: `q.bySeller` picks `Icons.storefront_rounded` in `_brand` over `Icons.person_rounded` in `_muted`, colours the name coral instead of ink, and spreads a collection-`if` that appends a `Seller` pill at `_brand.withValues(alpha: 0.12)`. Three signals for one boolean means an official answer is unmistakable at a glance.

The pinned composer, the send button and the model

ecom_product_qna_screen.dart
  Widget _composer() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(20, 10, 12, 10),
          child: Row(
            children: <Widget>[
              Expanded(
                child: Container(
                  height: 48,
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(24),
                  ),
                  child: Align(
                    alignment: Alignment.centerLeft,
                    child: TextField(
                      controller: _ask,
                      cursorColor: _brand,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w600,
                        color: _ink,
                      ),
                      decoration: const InputDecoration(
                        isDense: true,
                        border: InputBorder.none,
                        hintText: 'Ask a question…',
                        hintStyle: TextStyle(
                          fontFamily: _font,
                          fontSize: 14.5,
                          fontWeight: FontWeight.w500,
                          color: _faint,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 8),
              GestureDetector(
                onTap: () => widget.onAsk?.call(_ask.text),
                child: Container(
                  width: 48,
                  height: 48,
                  alignment: Alignment.center,
                  decoration: const BoxDecoration(
                    color: _brand,
                    shape: BoxShape.circle,
                  ),
                  child: const Icon(Icons.arrow_upward_rounded,
                      size: 22, color: _canvas),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Qa {
  _Qa(this.question, this.asker, this.answer, this.answerer, this.bySeller,
      this.votes);
  final String question;
  final String asker;
  final String answer;
  final String answerer;
  final bool bySeller;
  int votes;
}

`_composer` is a `_canvas` container with a top `_hairline` border wrapping `SafeArea(top: false)` — the border is on the outer container so the white background runs under the home indicator while the controls stay above it. The input is a 48px `_surface` pill at radius 24 (exactly half its height, giving true stadium ends), and the 48×48 circular `_brand` button beside it matches that height so the pair reads as one bar. Tapping calls `widget.onAsk?.call(_ask.text)` and nothing else — the screen never clears or posts, leaving submission to the host. `_Qa` closes the file with `votes` as the only non-final field.

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 — Questions & Answers.
///
/// Buyer Q&A for a product: a search field, a list of questions each with its
/// best answer (tagged seller or shopper), an upvote toggle and an answer count,
/// plus a pinned "ask a question" composer bar.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, no images (icons only).
/// Exposes callbacks only; the registry wires navigation.
class EcomProductQnaScreen extends StatefulWidget {
  const EcomProductQnaScreen({
    super.key,
    this.onBack,
    this.onAsk,
  });

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

  @override
  State<EcomProductQnaScreen> createState() => _EcomProductQnaScreenState();
}

class _EcomProductQnaScreenState extends State<EcomProductQnaScreen> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _hairline = Color(0xFFEBEBEB);

  final TextEditingController _ask = TextEditingController();
  final TextEditingController _search = TextEditingController();

  final List<_Qa> _items = <_Qa>[
    _Qa(
      'Does this run true to size?',
      'Asha · Jun 9',
      'It runs slightly boxy by design. If you prefer a trimmer fit, size down one — I usually wear M and took an S.',
      'StyleCart',
      true,
      24,
    ),
    _Qa(
      'Is the cotton heavy enough for autumn layering?',
      'Tom · Jun 5',
      'Mid-weight twill — great as an overshirt over a tee, and works under a coat once it gets cold.',
      'Daniel K.',
      false,
      11,
    ),
    _Qa(
      'Will the olive colour fade after washing?',
      'Priya · May 30',
      'Garment-dyed, so it softens a touch on the first wash then holds. Wash cold inside-out to keep it rich.',
      'StyleCart',
      true,
      9,
    ),
    _Qa(
      'Are the buttons real horn?',
      'Marco · May 24',
      'They\'re corozo (tagua nut) — natural and hard-wearing, very close to horn.',
      'StyleCart',
      true,
      6,
    ),
  ];

  final Set<int> _voted = <int>{};

  @override
  void dispose() {
    _ask.dispose();
    _search.dispose();
    super.dispose();
  }

  void _toggle(int i) {
    setState(() {
      if (_voted.contains(i)) {
        _voted.remove(i);
        _items[i].votes--;
      } else {
        _voted.add(i);
        _items[i].votes++;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              _searchBar(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView.separated(
                  padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
                  itemCount: _items.length,
                  separatorBuilder: (_, _) =>
                      const Divider(height: 1, color: _hairline),
                  itemBuilder: (BuildContext context, int i) =>
                      _card(_items[i], i),
                ),
              ),
              _composer(),
            ],
          ),
        ),
      ),
    );
  }

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

  Widget _searchBar() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
      child: Container(
        height: 46,
        padding: const EdgeInsets.symmetric(horizontal: 14),
        decoration: BoxDecoration(
          color: _surface,
          borderRadius: BorderRadius.circular(14),
        ),
        child: Row(
          children: <Widget>[
            const Icon(Icons.search_rounded, size: 20, color: _muted),
            const SizedBox(width: 10),
            Expanded(
              child: TextField(
                controller: _search,
                cursorColor: _brand,
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 14.5,
                  fontWeight: FontWeight.w600,
                  color: _ink,
                ),
                decoration: const InputDecoration(
                  isDense: true,
                  border: InputBorder.none,
                  hintText: 'Search questions',
                  hintStyle: TextStyle(
                    fontFamily: _font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w500,
                    color: _faint,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _card(_Qa q, int i) {
    final bool voted = _voted.contains(i);
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 16),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          GestureDetector(
            onTap: () => _toggle(i),
            child: Column(
              children: <Widget>[
                Icon(
                  Icons.keyboard_arrow_up_rounded,
                  size: 24,
                  color: voted ? _brand : _faint,
                ),
                Text(
                  '${q.votes}',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 13,
                    fontWeight: FontWeight.w800,
                    color: voted ? _brand : _muted,
                  ),
                ),
              ],
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    const Padding(
                      padding: EdgeInsets.only(top: 1),
                      child: Text(
                        'Q',
                        style: TextStyle(
                          fontFamily: _font,
                          fontSize: 14,
                          fontWeight: FontWeight.w800,
                          color: _brand,
                        ),
                      ),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: Text(
                        q.question,
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 15,
                          fontWeight: FontWeight.w700,
                          height: 1.3,
                          color: _ink,
                        ),
                      ),
                    ),
                  ],
                ),
                const SizedBox(height: 3),
                Padding(
                  padding: const EdgeInsets.only(left: 22),
                  child: Text(
                    q.asker,
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w500,
                      color: _muted,
                    ),
                  ),
                ),
                const SizedBox(height: 12),
                Container(
                  padding: const EdgeInsets.all(13),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text(
                        q.answer,
                        style: const TextStyle(
                          fontFamily: _font,
                          fontSize: 13.5,
                          fontWeight: FontWeight.w500,
                          height: 1.45,
                          color: _ink,
                        ),
                      ),
                      const SizedBox(height: 8),
                      Row(
                        children: <Widget>[
                          Icon(
                            q.bySeller
                                ? Icons.storefront_rounded
                                : Icons.person_rounded,
                            size: 14,
                            color: q.bySeller ? _brand : _muted,
                          ),
                          const SizedBox(width: 5),
                          Text(
                            q.answerer,
                            style: TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              fontWeight: FontWeight.w700,
                              color: q.bySeller ? _brand : _ink,
                            ),
                          ),
                          if (q.bySeller) ...<Widget>[
                            const SizedBox(width: 6),
                            Container(
                              padding: const EdgeInsets.symmetric(
                                  horizontal: 6, vertical: 1),
                              decoration: BoxDecoration(
                                color: _brand.withValues(alpha: 0.12),
                                borderRadius: BorderRadius.circular(5),
                              ),
                              child: const Text(
                                'Seller',
                                style: TextStyle(
                                  fontFamily: _font,
                                  fontSize: 10,
                                  fontWeight: FontWeight.w800,
                                  color: _brand,
                                ),
                              ),
                            ),
                          ],
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _composer() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(20, 10, 12, 10),
          child: Row(
            children: <Widget>[
              Expanded(
                child: Container(
                  height: 48,
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(24),
                  ),
                  child: Align(
                    alignment: Alignment.centerLeft,
                    child: TextField(
                      controller: _ask,
                      cursorColor: _brand,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 14.5,
                        fontWeight: FontWeight.w600,
                        color: _ink,
                      ),
                      decoration: const InputDecoration(
                        isDense: true,
                        border: InputBorder.none,
                        hintText: 'Ask a question…',
                        hintStyle: TextStyle(
                          fontFamily: _font,
                          fontSize: 14.5,
                          fontWeight: FontWeight.w500,
                          color: _faint,
                        ),
                      ),
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 8),
              GestureDetector(
                onTap: () => widget.onAsk?.call(_ask.text),
                child: Container(
                  width: 48,
                  height: 48,
                  alignment: Alignment.center,
                  decoration: const BoxDecoration(
                    color: _brand,
                    shape: BoxShape.circle,
                  ),
                  child: const Icon(Icons.arrow_upward_rounded,
                      size: 22, color: _canvas),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Qa {
  _Qa(this.question, this.asker, this.answer, this.answerer, this.bySeller,
      this.votes);
  final String question;
  final String asker;
  final String answer;
  final String answerer;
  final bool bySeller;
  int votes;
}

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-product-qna

2. AI agent (MCP)

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

FAQ

Can I use this Q&A screen in a commercial app for free?

Yes. FlutterKit is free — there is no paid tier, no licence key and no sign-up. Copy the Dart from this page, install it with the CLI, or pull it through MCP, and ship it in a store app. Attribution is not required.

Does the search field actually filter the questions?

Not in this file. `_search` is wired to the `TextField` but nothing listens to it, so the list always renders all four `_Qa` items. To make it live, add an `onChanged` that stores the query in state and build the list from `_items.where((q) => q.question.toLowerCase().contains(query))` — keep the original indexes if you do, because `_voted` and `_toggle` are keyed by list position.

How do I stop one person voting twice against a real backend?

The `Set<int> _voted` here is per-session and index-keyed, which is fine for a demo but not for a server. Swap the index for the question's own id, seed the set from whatever your API returns for the signed-in shopper, and send the toggle as an optimistic request — increment `q.votes` immediately, then roll back in the failure branch so the count never lies for long.

Which packages and fonts does it need?

No packages at all — it is pure `package:flutter/material.dart`, and every glyph is a built-in rounded Material icon, so there are no image assets. The one dependency is the Manrope font, referenced as the `_font` constant; bundle it in `pubspec.yaml` or point `_font` at a family you already ship.

Which Flutter version does this need?

Flutter 3.22 or newer for `_brand.withValues(alpha: 0.12)` on the Seller pill, plus a Dart 3.7-era SDK for the `(_, _)` wildcard parameters in `separatorBuilder`. On an older SDK use `withOpacity(0.12)`, rename the separator parameters to `(_, __)`, and expand the constructor to the `{Key? key}) : super(key: key)` form.

Related screens