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.

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