E-commerce49 views

How to Build a Product Reviews Screen in Flutter (Full Code + Preview)

A product page can promise anything; the reviews list is where a shopper decides whether to believe it. This Flutter tutorial builds StyleCart's full review screen: a 4.8 aggregate beside a painted 5→1 distribution whose bar widths come straight from the count fractions, a horizontal chip row for All / With photos / 5 stars / Verified, review cards with monogram avatars, painted per-review stars, photo strips and helpful counts, and a pinned Write a review bar. Every star and bar is a CustomPainter — no icon font, no emoji glyph.

Product Reviews — E-commerce Flutter UI screen
Live preview — Product Reviews, built in pure Flutter.

What you'll build

  • A 4.8 aggregate block whose review total is derived from the distribution list, never hard-coded twice
  • Five rating bars painted by `_DistBarPainter`, each fed `count / _total` as a fraction
  • A five-point star `Path` built from polar coordinates, drawn filled amber or stroked grey
  • Review cards with a letter-monogram avatar, a verified tick, an inline photo strip and a helpful counter
  • A `bottomSheet` Write a review bar that stays pinned while the review list scrolls beneath it

Step-by-step build

1

Create the file

Add a new file at lib/ecom_product_reviews/ecom_product_reviews_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.

Tokens, the rating distribution, and the filter labels

ecom_product_reviews_screen.dart
import 'dart:math' as math;

import 'package:flutter/material.dart';

/// StyleCart — Reviews.
///
/// The full review list for a product: an aggregate score with a painted star
/// row and a painted 5→1 distribution, filter chips (with photos / 5★ /
/// verified), and review cards with a monogram avatar, stars, optional photo
/// thumbnails and a helpful counter — plus a "write a review" action.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. Stars and the
/// distribution bars are CustomPainters (no emoji glyph). Exposes callbacks only.
class EcomProductReviewsScreen extends StatefulWidget {
  const EcomProductReviewsScreen({
    super.key,
    this.onBack,
    this.onReviewDetail,
    this.onWriteReview,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onReviewDetail;
  final VoidCallback? onWriteReview;

  @override
  State<EcomProductReviewsScreen> createState() =>
      _EcomProductReviewsScreenState();
}

class _EcomProductReviewsScreenState extends State<EcomProductReviewsScreen> {
  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 _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _imageBg = Color(0xFFF5F5F5);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const String _dir =
      'lib/screens/ecommerce/ecom_product_reviews/images';

  static const List<int> _dist = <int>[164, 32, 11, 4, 3]; // 5★ → 1★
  static const List<String> _filters = <String>[
    'All',
    'With photos',
    '5 stars',
    'Verified',
  ];

`EcomProductReviewsScreen` is a `StatefulWidget` purely because one chip is selectable; its three callbacks — `onBack`, `onReviewDetail`, `onWriteReview` — keep navigation outside the screen. The palette splits neutrals from meaning: `_ink` #222222 for text and the selected chip, `_hairline` #EBEBEB for every rule, `_success` #2E9E5B reserved for the verified tick, and `_brand` #FF385C used exactly once, on 'Read more'. The interesting constant is `_dist = [164, 32, 11, 4, 3]`, ordered 5★ down to 1★. Storing raw counts rather than percentages means the header total, the '214 reviews' caption and every bar width all fall out of the same numbers.

The review model instances and the derived total

ecom_product_reviews_screen.dart
  static const List<_Rv> _reviews = <_Rv>[
    _Rv('Maya R.', 5, 'Jun 8', true,
        'Perfect weight — drapes beautifully and the colour is exactly as shown. True to size and the buttons feel premium.',
        <String>['p06.webp', 'p07.webp'], 42),
    _Rv('Daniel K.', 4, 'Jun 3', true,
        'Great everyday overshirt. Runs slightly boxy so size down if you want a trimmer fit.',
        <String>[], 18),
    _Rv('Priya S.', 5, 'May 28', true,
        'Third StyleCart piece and the quality keeps surprising me. Washed cotton softens up even more after the first wash.',
        <String>['p08.webp', 'p09.webp', 'p10.webp'], 31),
    _Rv('Leah M.', 5, 'May 21', false,
        'Obsessed. The olive is such a good neutral and it layers over everything.',
        <String>['p11.webp'], 12),
  ];

  int _filter = 0;

  int get _total => _dist.reduce((int a, int b) => a + b);

Four `_Rv` records carry name, star count, date, verified flag, body, a photo filename list and a helpful count — one positional constructor, so the sample data reads as a compact table. The list deliberately mixes shapes: Daniel K. has an empty `photos` list, Leah M. is `verified: false` and has a single photo, Priya S. has three. That variety is what proves the card layout, because the photo strip and the tick are both conditional. `int get _total => _dist.reduce((a, b) => a + b)` computes 214 on demand; nothing in the file writes that figure as a literal, so editing `_dist` updates the header, the caption and all five bars at once.

The scroll structure and the header count

ecom_product_reviews_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(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.only(bottom: 96),
                  children: <Widget>[
                    _aggregate(),
                    const SizedBox(height: 8),
                    _filterRow(),
                    const Divider(height: 1, color: _hairline),
                    ..._reviews.map(_card),
                  ],
                ),
              ),
            ],
          ),
        ),
        bottomSheet: _writeBar(),
      ),
    );
  }

  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(
              'Reviews',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
          Text(
            '$_total total',
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13,
              fontWeight: FontWeight.w600,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

The body is a `Column` holding a fixed `_header()` and a hairline `Divider(height: 1)`, then an `Expanded` `ListView` — so the title row never scrolls away while 214 reviews move under it. The list's `padding: EdgeInsets.only(bottom: 96)` reserves room for the pinned `bottomSheet`, which otherwise sits on top of the final card's content. Reviews are spread in with `..._reviews.map(_card)` rather than an `itemBuilder`, because the aggregate block and chip row are siblings in the same scroll view. In the header, the 19px 'Reviews' title sits in an `Expanded` so the muted '$_total total' is pushed hard right against the 20px padding.

The aggregate score and the distribution bars

ecom_product_reviews_screen.dart
  Widget _aggregate() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          Column(
            children: <Widget>[
              const Text(
                '4.8',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 44,
                  fontWeight: FontWeight.w800,
                  height: 1,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 6),
              Row(
                children: List<Widget>.generate(
                  5,
                  (int i) => Padding(
                    padding: const EdgeInsets.only(right: 1.5),
                    child: SizedBox(
                      width: 14,
                      height: 14,
                      child: CustomPaint(painter: _StarPainter(filled: i < 5)),
                    ),
                  ),
                ),
              ),
              const SizedBox(height: 5),
              Text(
                '$_total reviews',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w500,
                  color: _muted,
                ),
              ),
            ],
          ),
          const SizedBox(width: 22),
          Expanded(
            child: Column(
              children: List<Widget>.generate(5, (int i) {
                final int star = 5 - i;
                final int count = _dist[i];
                return Padding(
                  padding: const EdgeInsets.symmetric(vertical: 2.5),
                  child: Row(
                    children: <Widget>[
                      SizedBox(
                        width: 10,
                        child: Text(
                          '$star',
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ),
                      const SizedBox(width: 6),
                      Expanded(
                        child: SizedBox(
                          height: 7,
                          child: CustomPaint(
                            painter: _DistBarPainter(count / _total),
                          ),
                        ),
                      ),
                      const SizedBox(width: 8),
                      SizedBox(
                        width: 26,
                        child: Text(
                          '$count',
                          textAlign: TextAlign.right,
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ),
                    ],
                  ),
                );
              }),
            ),
          ),
        ],
      ),
    );
  }

The left column stacks a 44px `w800` '4.8' with `height: 1` — collapsing the line box so the star row sits tight beneath the digits — then five 14×14 `_StarPainter` boxes generated with `filled: i < 5`, then the '214 reviews' caption. The right side is `Expanded`, so the bars absorb whatever width remains. Each of the five rows is built from `star = 5 - i` and `count = _dist[i]`, giving the conventional 5-at-the-top ordering. The fraction maths lives in one place: `_DistBarPainter(count / _total)` receives 164/214 ≈ 0.766 for five stars and 3/214 ≈ 0.014 for one. Fixed `SizedBox` widths of 10 for the numeral and 26 for the count keep every bar starting and ending on the same x, so the bars read as a chart rather than five unrelated pills.

Filter chips and their selected state

ecom_product_reviews_screen.dart
  Widget _filterRow() {
    return SizedBox(
      height: 56,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
        itemCount: _filters.length,
        separatorBuilder: (_, _) => const SizedBox(width: 8),
        itemBuilder: (BuildContext context, int i) {
          final bool on = i == _filter;
          return GestureDetector(
            onTap: () => setState(() => _filter = i),
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: on ? _ink : _canvas,
                borderRadius: BorderRadius.circular(20),
                border: Border.all(color: on ? _ink : _hairline),
              ),
              child: Text(
                _filters[i],
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13.5,
                  fontWeight: FontWeight.w700,
                  color: on ? _canvas : _ink,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

A 56px-tall horizontal `ListView.separated` with 8px separators renders the four labels from `_filters`. `final bool on = i == _filter` drives three properties at once: fill flips from `_canvas` to `_ink`, the border colour follows the fill so an unselected chip shows only a `_hairline` outline, and the text inverts to white. Because the border is always present at the same width, selecting a chip never changes its measured size — no reflow, no jump in the row. Tapping calls `setState(() => _filter = i)`, which repaints the chips. Note what the Dart does *not* do: `_reviews` is still spread in full in `build`, so the chips currently record a choice rather than narrowing the list — the hook to filter on is `_filter`.

Inside a review card

ecom_product_reviews_screen.dart
  Widget _card(_Rv r) {
    return GestureDetector(
      onTap: () => widget.onReviewDetail?.call(r.name),
      behavior: HitTestBehavior.opaque,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                Container(
                  width: 38,
                  height: 38,
                  alignment: Alignment.center,
                  decoration: const BoxDecoration(
                    color: _surface,
                    shape: BoxShape.circle,
                  ),
                  child: Text(
                    r.name.substring(0, 1),
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 15,
                      fontWeight: FontWeight.w800,
                      color: _ink,
                    ),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Row(
                        children: <Widget>[
                          Text(
                            r.name,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 14.5,
                              fontWeight: FontWeight.w700,
                              color: _ink,
                            ),
                          ),
                          if (r.verified) ...<Widget>[
                            const SizedBox(width: 6),
                            const Icon(Icons.verified_rounded,
                                size: 15, color: _success),
                          ],
                        ],
                      ),
                      const SizedBox(height: 3),
                      Row(
                        children: <Widget>[
                          ...List<Widget>.generate(
                            5,
                            (int i) => Padding(
                              padding: const EdgeInsets.only(right: 1.5),
                              child: SizedBox(
                                width: 11,
                                height: 11,
                                child: CustomPaint(
                                    painter: _StarPainter(filled: i < r.stars)),
                              ),
                            ),
                          ),
                          const SizedBox(width: 8),
                          Text(
                            r.date,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              fontWeight: FontWeight.w500,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Text(
              r.body,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w500,
                height: 1.5,
                color: _ink,
              ),
            ),
            if (r.photos.isNotEmpty) ...<Widget>[
              const SizedBox(height: 12),
              SizedBox(
                height: 72,
                child: ListView.separated(
                  scrollDirection: Axis.horizontal,
                  itemCount: r.photos.length,
                  separatorBuilder: (_, _) => const SizedBox(width: 8),
                  itemBuilder: (BuildContext context, int i) => ClipRRect(
                    borderRadius: BorderRadius.circular(10),
                    child: Container(
                      width: 72,
                      color: _imageBg,
                      child: Image.asset('$_dir/${r.photos[i]}',
                          fit: BoxFit.cover),
                    ),
                  ),
                ),
              ),
            ],
            const SizedBox(height: 12),
            Row(
              children: <Widget>[
                const Icon(Icons.thumb_up_outlined, size: 16, color: _muted),
                const SizedBox(width: 6),
                Text(
                  'Helpful (${r.helpful})',
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
                const Spacer(),
                const Text(
                  'Read more',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w700,
                    color: _brand,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 4),
            const Divider(height: 1, color: _hairline),
          ],
        ),
      ),
    );
  }

The whole card is a `GestureDetector` with `behavior: HitTestBehavior.opaque`, so taps land in the padding gaps too and fire `onReviewDetail?.call(r.name)`. The avatar is a 38px `_surface` circle containing `r.name.substring(0, 1)` — a monogram costs no network image and never fails to load. The verified tick is spread in with `if (r.verified) ...[SizedBox(width: 6), Icon(...)]`, so an unverified name has no leftover gap. Per-review stars reuse `_StarPainter` at 11px with `filled: i < r.stars`, which is why Daniel's four-star row shows one stroked outline. The photo strip is itself conditional and horizontally scrollable at 72px square, each thumbnail `ClipRRect`-ed to a 10px radius over an `_imageBg` container so a slow-loading asset shows a neutral tile rather than white. The footer pairs a muted 'Helpful (42)' with a `Spacer()` and a brand-coloured 'Read more', and the card closes with its own hairline `Divider` — separators drawn per-card rather than by the list.

The pinned write-a-review bar and the _Rv record

ecom_product_reviews_screen.dart
  Widget _writeBar() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
          child: SizedBox(
            height: 52,
            width: double.infinity,
            child: OutlinedButton.icon(
              onPressed: widget.onWriteReview,
              icon: const Icon(Icons.rate_review_outlined, size: 19),
              style: OutlinedButton.styleFrom(
                foregroundColor: _ink,
                side: const BorderSide(color: _ink, width: 1.4),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(14),
                ),
                textStyle: const TextStyle(
                  fontFamily: _font,
                  fontSize: 15,
                  fontWeight: FontWeight.w700,
                ),
              ),
              label: const Text('Write a review'),
            ),
          ),
        ),
      ),
    );
  }
}

class _Rv {
  const _Rv(this.name, this.stars, this.date, this.verified, this.body,
      this.photos, this.helpful);
  final String name;
  final int stars;
  final String date;
  final bool verified;
  final String body;
  final List<String> photos;
  final int helpful;
}

`_writeBar()` is passed to `Scaffold.bottomSheet`, not stacked in the `Column`, which is what keeps it fixed while the `ListView` scrolls behind it. Its `Container` paints `_canvas` with a top `BorderSide(color: _hairline)` and wraps `SafeArea(top: false)` *inside* itself, so the white background bleeds under the home indicator while the button stays above it. The action is an `OutlinedButton.icon` with a 1.4px `_ink` border and a 14px radius — outlined, not filled, because writing a review is an invitation rather than the screen's main job; a coral fill here would compete with reading. `_Rv` is a plain immutable class with seven final fields, which is all the screen needs from a review.

Painting the bars and the five-point stars

ecom_product_reviews_screen.dart
/// Paints a rounded distribution bar — faint track + brand-amber fill.
class _DistBarPainter extends CustomPainter {
  _DistBarPainter(this.fraction);
  final double fraction;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect track = RRect.fromRectAndRadius(
      Offset.zero & size,
      Radius.circular(size.height / 2),
    );
    canvas.drawRRect(track, Paint()..color = const Color(0xFFEBEBEB));
    final double w = (size.width * fraction).clamp(size.height, size.width);
    final RRect fill = RRect.fromRectAndRadius(
      Rect.fromLTWH(0, 0, w, size.height),
      Radius.circular(size.height / 2),
    );
    canvas.drawRRect(fill, Paint()..color = const Color(0xFFF5A623));
  }

  @override
  bool shouldRepaint(_DistBarPainter old) => old.fraction != fraction;
}

/// Paints a five-point star, filled (amber) or outlined (faint).
class _StarPainter extends CustomPainter {
  const _StarPainter({required this.filled});
  final bool filled;

  @override
  void paint(Canvas canvas, Size size) {
    final Path star = _starPath(
        size.width / 2, size.height / 2, size.width / 2, size.width / 4);
    if (filled) {
      canvas.drawPath(star, Paint()..color = const Color(0xFFF5A623));
    } else {
      canvas.drawPath(
        star,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 1.3
          ..strokeJoin = StrokeJoin.round
          ..color = const Color(0xFFC1C1C1),
      );
    }
  }

  Path _starPath(double cx, double cy, double outer, double inner) {
    final Path p = Path();
    const double step = math.pi / 5;
    double a = -math.pi / 2;
    for (int i = 0; i < 10; i++) {
      final double r = i.isEven ? outer : inner;
      final double x = cx + r * math.cos(a);
      final double y = cy + r * math.sin(a);
      if (i == 0) {
        p.moveTo(x, y);
      } else {
        p.lineTo(x, y);
      }
      a += step;
    }
    p.close();
    return p;
  }

  @override
  bool shouldRepaint(_StarPainter old) => old.filled != filled;
}

`_DistBarPainter` draws a #EBEBEB track as an `RRect` with `Radius.circular(size.height / 2)` — a fully rounded 7px bar — then an amber #F5A623 fill of width `(size.width * fraction).clamp(size.height, size.width)`. That clamp is the detail worth copying: at 0.014 the one-star fill would be under a pixel and vanish, so the floor of `size.height` guarantees at least a visible dot while the ceiling stops rounding error overflowing the track. `_StarPainter` generates its `Path` from polar coordinates: ten points stepping `math.pi / 5`, starting at `-math.pi / 2` so a vertex points straight up, alternating between an outer radius of `width / 2` and an inner of `width / 4` — that 2:1 ratio is what gives the classic star proportion. Filled stars are painted solid amber; empty ones are stroked at 1.3px in #C1C1C1 with `StrokeJoin.round`. Both `shouldRepaint` methods compare their single field, so a rebuild only repaints what changed.

Full code

The complete, ready-to-paste source. Free to use in your projects — one click copies it all.

import 'dart:math' as math;

import 'package:flutter/material.dart';

/// StyleCart — Reviews.
///
/// The full review list for a product: an aggregate score with a painted star
/// row and a painted 5→1 distribution, filter chips (with photos / 5★ /
/// verified), and review cards with a monogram avatar, stars, optional photo
/// thumbnails and a helpful counter — plus a "write a review" action.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. Stars and the
/// distribution bars are CustomPainters (no emoji glyph). Exposes callbacks only.
class EcomProductReviewsScreen extends StatefulWidget {
  const EcomProductReviewsScreen({
    super.key,
    this.onBack,
    this.onReviewDetail,
    this.onWriteReview,
  });

  final VoidCallback? onBack;
  final ValueChanged<String>? onReviewDetail;
  final VoidCallback? onWriteReview;

  @override
  State<EcomProductReviewsScreen> createState() =>
      _EcomProductReviewsScreenState();
}

class _EcomProductReviewsScreenState extends State<EcomProductReviewsScreen> {
  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 _brand = Color(0xFFFF385C);
  static const Color _surface = Color(0xFFF2F2F2);
  static const Color _imageBg = Color(0xFFF5F5F5);
  static const Color _success = Color(0xFF2E9E5B);
  static const Color _hairline = Color(0xFFEBEBEB);

  static const String _dir =
      'lib/screens/ecommerce/ecom_product_reviews/images';

  static const List<int> _dist = <int>[164, 32, 11, 4, 3]; // 5★ → 1★
  static const List<String> _filters = <String>[
    'All',
    'With photos',
    '5 stars',
    'Verified',
  ];

  static const List<_Rv> _reviews = <_Rv>[
    _Rv('Maya R.', 5, 'Jun 8', true,
        'Perfect weight — drapes beautifully and the colour is exactly as shown. True to size and the buttons feel premium.',
        <String>['p06.webp', 'p07.webp'], 42),
    _Rv('Daniel K.', 4, 'Jun 3', true,
        'Great everyday overshirt. Runs slightly boxy so size down if you want a trimmer fit.',
        <String>[], 18),
    _Rv('Priya S.', 5, 'May 28', true,
        'Third StyleCart piece and the quality keeps surprising me. Washed cotton softens up even more after the first wash.',
        <String>['p08.webp', 'p09.webp', 'p10.webp'], 31),
    _Rv('Leah M.', 5, 'May 21', false,
        'Obsessed. The olive is such a good neutral and it layers over everything.',
        <String>['p11.webp'], 12),
  ];

  int _filter = 0;

  int get _total => _dist.reduce((int a, int b) => a + b);

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          child: Column(
            children: <Widget>[
              _header(),
              const Divider(height: 1, color: _hairline),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.only(bottom: 96),
                  children: <Widget>[
                    _aggregate(),
                    const SizedBox(height: 8),
                    _filterRow(),
                    const Divider(height: 1, color: _hairline),
                    ..._reviews.map(_card),
                  ],
                ),
              ),
            ],
          ),
        ),
        bottomSheet: _writeBar(),
      ),
    );
  }

  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(
              'Reviews',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 19,
                fontWeight: FontWeight.w800,
                letterSpacing: -0.3,
                color: _ink,
              ),
            ),
          ),
          Text(
            '$_total total',
            style: const TextStyle(
              fontFamily: _font,
              fontSize: 13,
              fontWeight: FontWeight.w600,
              color: _muted,
            ),
          ),
        ],
      ),
    );
  }

  Widget _aggregate() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 18, 20, 14),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: <Widget>[
          Column(
            children: <Widget>[
              const Text(
                '4.8',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 44,
                  fontWeight: FontWeight.w800,
                  height: 1,
                  color: _ink,
                ),
              ),
              const SizedBox(height: 6),
              Row(
                children: List<Widget>.generate(
                  5,
                  (int i) => Padding(
                    padding: const EdgeInsets.only(right: 1.5),
                    child: SizedBox(
                      width: 14,
                      height: 14,
                      child: CustomPaint(painter: _StarPainter(filled: i < 5)),
                    ),
                  ),
                ),
              ),
              const SizedBox(height: 5),
              Text(
                '$_total reviews',
                style: const TextStyle(
                  fontFamily: _font,
                  fontSize: 12.5,
                  fontWeight: FontWeight.w500,
                  color: _muted,
                ),
              ),
            ],
          ),
          const SizedBox(width: 22),
          Expanded(
            child: Column(
              children: List<Widget>.generate(5, (int i) {
                final int star = 5 - i;
                final int count = _dist[i];
                return Padding(
                  padding: const EdgeInsets.symmetric(vertical: 2.5),
                  child: Row(
                    children: <Widget>[
                      SizedBox(
                        width: 10,
                        child: Text(
                          '$star',
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ),
                      const SizedBox(width: 6),
                      Expanded(
                        child: SizedBox(
                          height: 7,
                          child: CustomPaint(
                            painter: _DistBarPainter(count / _total),
                          ),
                        ),
                      ),
                      const SizedBox(width: 8),
                      SizedBox(
                        width: 26,
                        child: Text(
                          '$count',
                          textAlign: TextAlign.right,
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 12,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ),
                    ],
                  ),
                );
              }),
            ),
          ),
        ],
      ),
    );
  }

  Widget _filterRow() {
    return SizedBox(
      height: 56,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
        itemCount: _filters.length,
        separatorBuilder: (_, _) => const SizedBox(width: 8),
        itemBuilder: (BuildContext context, int i) {
          final bool on = i == _filter;
          return GestureDetector(
            onTap: () => setState(() => _filter = i),
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              alignment: Alignment.center,
              decoration: BoxDecoration(
                color: on ? _ink : _canvas,
                borderRadius: BorderRadius.circular(20),
                border: Border.all(color: on ? _ink : _hairline),
              ),
              child: Text(
                _filters[i],
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 13.5,
                  fontWeight: FontWeight.w700,
                  color: on ? _canvas : _ink,
                ),
              ),
            ),
          );
        },
      ),
    );
  }

  Widget _card(_Rv r) {
    return GestureDetector(
      onTap: () => widget.onReviewDetail?.call(r.name),
      behavior: HitTestBehavior.opaque,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                Container(
                  width: 38,
                  height: 38,
                  alignment: Alignment.center,
                  decoration: const BoxDecoration(
                    color: _surface,
                    shape: BoxShape.circle,
                  ),
                  child: Text(
                    r.name.substring(0, 1),
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 15,
                      fontWeight: FontWeight.w800,
                      color: _ink,
                    ),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Row(
                        children: <Widget>[
                          Text(
                            r.name,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 14.5,
                              fontWeight: FontWeight.w700,
                              color: _ink,
                            ),
                          ),
                          if (r.verified) ...<Widget>[
                            const SizedBox(width: 6),
                            const Icon(Icons.verified_rounded,
                                size: 15, color: _success),
                          ],
                        ],
                      ),
                      const SizedBox(height: 3),
                      Row(
                        children: <Widget>[
                          ...List<Widget>.generate(
                            5,
                            (int i) => Padding(
                              padding: const EdgeInsets.only(right: 1.5),
                              child: SizedBox(
                                width: 11,
                                height: 11,
                                child: CustomPaint(
                                    painter: _StarPainter(filled: i < r.stars)),
                              ),
                            ),
                          ),
                          const SizedBox(width: 8),
                          Text(
                            r.date,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 12,
                              fontWeight: FontWeight.w500,
                              color: _muted,
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Text(
              r.body,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 13.5,
                fontWeight: FontWeight.w500,
                height: 1.5,
                color: _ink,
              ),
            ),
            if (r.photos.isNotEmpty) ...<Widget>[
              const SizedBox(height: 12),
              SizedBox(
                height: 72,
                child: ListView.separated(
                  scrollDirection: Axis.horizontal,
                  itemCount: r.photos.length,
                  separatorBuilder: (_, _) => const SizedBox(width: 8),
                  itemBuilder: (BuildContext context, int i) => ClipRRect(
                    borderRadius: BorderRadius.circular(10),
                    child: Container(
                      width: 72,
                      color: _imageBg,
                      child: Image.asset('$_dir/${r.photos[i]}',
                          fit: BoxFit.cover),
                    ),
                  ),
                ),
              ),
            ],
            const SizedBox(height: 12),
            Row(
              children: <Widget>[
                const Icon(Icons.thumb_up_outlined, size: 16, color: _muted),
                const SizedBox(width: 6),
                Text(
                  'Helpful (${r.helpful})',
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w600,
                    color: _muted,
                  ),
                ),
                const Spacer(),
                const Text(
                  'Read more',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w700,
                    color: _brand,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 4),
            const Divider(height: 1, color: _hairline),
          ],
        ),
      ),
    );
  }

  Widget _writeBar() {
    return Container(
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      child: SafeArea(
        top: false,
        child: Padding(
          padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
          child: SizedBox(
            height: 52,
            width: double.infinity,
            child: OutlinedButton.icon(
              onPressed: widget.onWriteReview,
              icon: const Icon(Icons.rate_review_outlined, size: 19),
              style: OutlinedButton.styleFrom(
                foregroundColor: _ink,
                side: const BorderSide(color: _ink, width: 1.4),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(14),
                ),
                textStyle: const TextStyle(
                  fontFamily: _font,
                  fontSize: 15,
                  fontWeight: FontWeight.w700,
                ),
              ),
              label: const Text('Write a review'),
            ),
          ),
        ),
      ),
    );
  }
}

class _Rv {
  const _Rv(this.name, this.stars, this.date, this.verified, this.body,
      this.photos, this.helpful);
  final String name;
  final int stars;
  final String date;
  final bool verified;
  final String body;
  final List<String> photos;
  final int helpful;
}

/// Paints a rounded distribution bar — faint track + brand-amber fill.
class _DistBarPainter extends CustomPainter {
  _DistBarPainter(this.fraction);
  final double fraction;

  @override
  void paint(Canvas canvas, Size size) {
    final RRect track = RRect.fromRectAndRadius(
      Offset.zero & size,
      Radius.circular(size.height / 2),
    );
    canvas.drawRRect(track, Paint()..color = const Color(0xFFEBEBEB));
    final double w = (size.width * fraction).clamp(size.height, size.width);
    final RRect fill = RRect.fromRectAndRadius(
      Rect.fromLTWH(0, 0, w, size.height),
      Radius.circular(size.height / 2),
    );
    canvas.drawRRect(fill, Paint()..color = const Color(0xFFF5A623));
  }

  @override
  bool shouldRepaint(_DistBarPainter old) => old.fraction != fraction;
}

/// Paints a five-point star, filled (amber) or outlined (faint).
class _StarPainter extends CustomPainter {
  const _StarPainter({required this.filled});
  final bool filled;

  @override
  void paint(Canvas canvas, Size size) {
    final Path star = _starPath(
        size.width / 2, size.height / 2, size.width / 2, size.width / 4);
    if (filled) {
      canvas.drawPath(star, Paint()..color = const Color(0xFFF5A623));
    } else {
      canvas.drawPath(
        star,
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 1.3
          ..strokeJoin = StrokeJoin.round
          ..color = const Color(0xFFC1C1C1),
      );
    }
  }

  Path _starPath(double cx, double cy, double outer, double inner) {
    final Path p = Path();
    const double step = math.pi / 5;
    double a = -math.pi / 2;
    for (int i = 0; i < 10; i++) {
      final double r = i.isEven ? outer : inner;
      final double x = cx + r * math.cos(a);
      final double y = cy + r * math.sin(a);
      if (i == 0) {
        p.moveTo(x, y);
      } else {
        p.lineTo(x, y);
      }
      a += step;
    }
    p.close();
    return p;
  }

  @override
  bool shouldRepaint(_StarPainter old) => old.filled != filled;
}

Plus bundled 11 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-reviews

2. AI agent (MCP)

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

FAQ

Can I use this reviews screen in a commercial app?

Yes. FlutterKit is free — there is no paid tier behind this page, 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 paid app. Attribution is not required.

Does it need any packages or a font?

No packages at all — `dart:math` and `package:flutter/material.dart` are the only imports, and the stars and bars are `CustomPainter`s rather than an icon pack. The text uses bundled Manrope via `fontFamily: 'Manrope'`; declare it in your pubspec or delete the `fontFamily` lines to fall back to the platform font.

How do I make the filter chips actually narrow the list?

The chips currently only store the selection in `_filter`. Add a getter that maps the index over `_reviews` — index 1 keeping `r.photos.isNotEmpty`, index 2 keeping `r.stars == 5`, index 3 keeping `r.verified`, index 0 keeping everything — then spread that getter in `build` instead of `_reviews`. `setState` already rebuilds, so no other change is needed.

Where do the rating bar widths come from?

From the counts themselves. `_total` reduces `_dist` to 214, and each bar receives `count / _total` as a plain fraction, which `_DistBarPainter` multiplies by the available width. Swap in your own counts and the bars, the header total and the caption all follow — nothing needs recalculating by hand.

Which Flutter version does this screen need?

As written it needs Flutter 3.29 (Dart 3.7) because both `ListView.separated` builders use wildcard parameters, `(_, _) =>`. On Flutter 3.22 to 3.27 just rename the second one, `(_, __) =>`, and everything else compiles as-is since super parameters (`super.key`) have been available since 3.10. On anything older, also expand the constructor to the `{Key? key}) : super(key: key)` form and swap any `withValues(alpha: x)` you add for `withOpacity(x)`.

Related screens