E-commerce68 views

How to Build an E-commerce Store Detail Screen in Flutter (Full Code + Preview)

A store-locator detail page has one job: convince someone standing on the sidewalk — or planning to be — that the trip is worth it. This tutorial builds StyleCart's SoHo flagship page in Flutter: a swipeable photo carousel with animated dots, a rating row with a hand-painted star, an address card topped by a CustomPainter mini-map, a four-icon services strip, a tap-to-expand weekly hours accordion, and a pinned bottom bar with Directions and Call. Everything is painted or bundled, so the screen runs with zero packages and no network calls.

Store Detail — E-commerce Flutter UI screen
Live preview — Store Detail, built in pure Flutter.

What you'll build

  • A swipeable PageView photo carousel with pill-shaped animated dot indicators and a floating translucent back button
  • A painted mini-map — tinted land, white street grid, park patch and a brand pin — with no map SDK at all
  • A rating row whose five-point star is drawn by a CustomPainter using an inline sine approximation instead of dart:math
  • A weekly opening-hours accordion that shows today's range when collapsed and all seven days when expanded
  • A pinned bottom bar that ranks a filled Directions pill above a small outlined Call circle

Step-by-step build

1

Create the file

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

Three callbacks, a token palette, and the store's data as constants

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

/// StyleCart — Store detail.
///
/// A single physical store page: a swipeable photo carousel (bundled webp +
/// painted dots), name + open-now status + rating, an address card with a
/// painted mini-map preview, a services row (pickup / try-on / alterations /
/// returns), a weekly hours accordion, and a pinned bottom bar (Directions +
/// Call).
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (mini-map, rating star). Reused bundled webp. Exposes callbacks only.
class EcomStoreDetailScreen extends StatefulWidget {
  const EcomStoreDetailScreen({
    super.key,
    this.onBack,
    this.onDirections,
    this.onCall,
  });

  final VoidCallback? onBack;
  final VoidCallback? onDirections;
  final VoidCallback? onCall;

  @override
  State<EcomStoreDetailScreen> createState() => _EcomStoreDetailScreenState();
}

class _EcomStoreDetailScreenState extends State<EcomStoreDetailScreen> {
  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 _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);

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

  static const List<String> _photos = <String>[
    '$_dir/p02.webp',
    '$_dir/p03.webp',
    '$_dir/p21.webp',
  ];

  static const List<_Service> _services = <_Service>[
    _Service(Icons.shopping_bag_outlined, 'Pickup'),
    _Service(Icons.checkroom_outlined, 'Try-on'),
    _Service(Icons.content_cut_rounded, 'Alterations'),
    _Service(Icons.assignment_return_outlined, 'Returns'),
  ];

  static const List<_Hours> _hours = <_Hours>[
    _Hours('Monday', '10:00 AM – 9:00 PM'),
    _Hours('Tuesday', '10:00 AM – 9:00 PM'),
    _Hours('Wednesday', '10:00 AM – 9:00 PM'),
    _Hours('Thursday', '10:00 AM – 9:00 PM'),
    _Hours('Friday', '10:00 AM – 10:00 PM'),
    _Hours('Saturday', '9:00 AM – 10:00 PM'),
    _Hours('Sunday', '11:00 AM – 7:00 PM'),
  ];

  int _photo = 0;
  bool _hoursOpen = false;

`EcomStoreDetailScreen` exposes exactly three callbacks — `onBack`, `onDirections`, `onCall` — because everything else on the page is informational; navigation and phone dialling are the only things the host app needs to own. The state class opens with an Airbnb-flavoured palette: `_brand` is the Rausch-like `0xFFFF385C`, `_success` a green reserved for the open-now badge, and `_hairline` the `0xFFEBEBEB` used on every card border. The three photo paths, four `_Service` entries and seven `_Hours` rows are all `static const` lists, so the carousel, services strip and accordion are generated from data rather than hand-copied widgets. Only two fields are mutable: `_photo` (the visible carousel page) and `_hoursOpen` (whether the accordion is expanded).

A scrolling body with a pinned action bar

ecom_store_detail_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              Expanded(
                child: ListView(
                  padding: EdgeInsets.zero,
                  children: <Widget>[
                    _gallery(),
                    Padding(
                      padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          _title(),
                          const SizedBox(height: 16),
                          _addressCard(),
                          const SizedBox(height: 18),
                          _servicesRow(),
                          const SizedBox(height: 18),
                          _hoursCard(),
                          const SizedBox(height: 18),
                          _aboutCard(),
                        ],
                      ),
                    ),
                    const SizedBox(height: 16),
                  ],
                ),
              ),
              _bottomBar(),
            ],
          ),
        ),
      ),
    );
  }

`build` wraps everything in `Theme(data: ThemeData.light(useMaterial3: true))` so the screen keeps its own light look regardless of the host app's theme. The `SafeArea` sets `top: false` on purpose — the photo carousel is meant to run edge-to-edge under the status bar, and the back button compensates manually later. The layout is the classic detail-page skeleton: a `Column` whose `Expanded` child is a `ListView` (with `padding: EdgeInsets.zero` so the gallery sits flush against the top), followed by `_bottomBar()` as a sibling, which is what keeps Directions and Call pinned while the content scrolls beneath them.

The photo carousel: PageView, frosted back button, animated dots

ecom_store_detail_screen.dart
  Widget _gallery() {
    return SizedBox(
      height: 280,
      child: Stack(
        children: <Widget>[
          PageView.builder(
            onPageChanged: (int i) => setState(() => _photo = i),
            itemCount: _photos.length,
            itemBuilder: (BuildContext _, int i) =>
                Image.asset(_photos[i], fit: BoxFit.cover),
          ),
          Positioned(
            top: MediaQuery.of(context).padding.top + 6,
            left: 8,
            child: Material(
              color: Colors.white.withValues(alpha: 0.92),
              shape: const CircleBorder(),
              child: InkWell(
                customBorder: const CircleBorder(),
                onTap: widget.onBack,
                child: const Padding(
                  padding: EdgeInsets.all(8),
                  child: Icon(Icons.arrow_back_rounded, size: 20, color: _ink),
                ),
              ),
            ),
          ),
          Positioned(
            bottom: 12,
            left: 0,
            right: 0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                for (int i = 0; i < _photos.length; i++)
                  AnimatedContainer(
                    duration: const Duration(milliseconds: 200),
                    margin: const EdgeInsets.symmetric(horizontal: 3),
                    width: i == _photo ? 20 : 7,
                    height: 7,
                    decoration: BoxDecoration(
                      color: i == _photo
                          ? Colors.white
                          : Colors.white.withValues(alpha: 0.6),
                      borderRadius: BorderRadius.circular(4),
                    ),
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }

The gallery is a 280px `Stack`. Underneath, `PageView.builder` swipes through the three bundled webp photos and reports the page via `onPageChanged: (i) => setState(() => _photo = i)`. The back button is `Positioned` at `MediaQuery.of(context).padding.top + 6` — that manual offset exists precisely because `SafeArea(top: false)` let the image climb under the status bar. It is a `Material` at `Colors.white.withValues(alpha: 0.92)` with a `CircleBorder`, so the `InkWell` ripple clips to the circle. The dots are the nicest detail: each is an `AnimatedContainer` whose width animates between 20 and 7 over 200ms, so the active dot stretches into a pill while inactive ones fade to 60% white.

Title row: painted star, review count, and the open-now badge

ecom_store_detail_screen.dart
  Widget _title() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        const Text(
          'StyleCart · SoHo Flagship',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 22,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.4,
            color: _ink,
          ),
        ),
        const SizedBox(height: 6),
        Row(
          children: <Widget>[
            CustomPaint(size: const Size(13, 13), painter: _StarPainter()),
            const SizedBox(width: 4),
            const Text(
              '4.8',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w800,
                color: _ink,
              ),
            ),
            const SizedBox(width: 4),
            const Text(
              '(1,204)',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w600,
                color: _muted,
              ),
            ),
            const SizedBox(width: 10),
            Container(width: 3, height: 3, decoration: const BoxDecoration(
                color: _faintDot, shape: BoxShape.circle)),
            const SizedBox(width: 10),
            Container(
              padding:
                  const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
              decoration: BoxDecoration(
                color: _success.withValues(alpha: 0.12),
                borderRadius: BorderRadius.circular(7),
              ),
              child: const Text(
                'Open until 9 PM',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 11.5,
                  fontWeight: FontWeight.w800,
                  color: _success,
                ),
              ),
            ),
          ],
        ),
      ],
    );
  }

  static const Color _faintDot = Color(0xFFC1C1C1);

The store name sits at 22px `w800` with `letterSpacing: -0.4`, then a metadata row packs four facts into one line. The star is not an icon — it's `CustomPaint(size: Size(13, 13), painter: _StarPainter())`, keeping it exactly on-brand red. The bold '4.8' and muted '(1,204)' use weight and colour, not size, to establish hierarchy, and a 3px `_faintDot` circle separates rating from status. The 'Open until 9 PM' badge is a pill tinted `_success.withValues(alpha: 0.12)` with full-strength `_success` text — the standard tinted-chip recipe that reads as a status light without shouting.

The address card with a painted mini-map, then the services strip

ecom_store_detail_screen.dart
  Widget _addressCard() {
    return Container(
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          ClipRRect(
            borderRadius:
                const BorderRadius.vertical(top: Radius.circular(16)),
            child: CustomPaint(
              painter: _MiniMapPainter(),
              child: const SizedBox(height: 110, width: double.infinity),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(14),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Icon(Icons.place_rounded, size: 20, color: _brand),
                const SizedBox(width: 10),
                const Expanded(
                  child: Text(
                    '112 Spring Street, SoHo\nNew York, NY 10012',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w600,
                      height: 1.4,
                      color: _ink,
                    ),
                  ),
                ),
                const Text(
                  '0.6 mi',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w800,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _servicesRow() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        const Text(
          'In-store services',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 16,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.2,
            color: _ink,
          ),
        ),
        const SizedBox(height: 12),
        Row(
          children: <Widget>[
            for (final _Service s in _services) ...<Widget>[
              Expanded(
                child: Column(
                  children: <Widget>[
                    Container(
                      width: 52,
                      height: 52,
                      decoration: BoxDecoration(
                        color: _surface,
                        borderRadius: BorderRadius.circular(14),
                      ),
                      child: Icon(s.icon, size: 22, color: _ink),
                    ),
                    const SizedBox(height: 6),
                    Text(
                      s.label,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 11.5,
                        fontWeight: FontWeight.w700,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ],
        ),
      ],
    );
  }

The address card is a hairline-bordered container whose top half is a `ClipRRect` (rounded on top only, matching the card's 16px radius) over `CustomPaint(painter: _MiniMapPainter())` sized 110px tall — a map preview with zero SDK dependencies. Below it, a `place_rounded` icon in `_brand` sits beside the two-line address in an `Expanded`, with '0.6 mi' right-aligned as the decision-making number. The services row then loops `for (final _Service s in _services)` inside a `Row`, wrapping each icon in an `Expanded` so Pickup, Try-on, Alterations and Returns split the width into four equal columns — each a 52px `_surface` square with a 14px radius and an 11.5px muted label.

The opening-hours accordion

ecom_store_detail_screen.dart
  Widget _hoursCard() {
    final _Hours today = _hours[4]; // pretend "today" is Friday for the summary
    return Container(
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          InkWell(
            onTap: () => setState(() => _hoursOpen = !_hoursOpen),
            borderRadius: BorderRadius.circular(16),
            child: Padding(
              padding: const EdgeInsets.all(14),
              child: Row(
                children: <Widget>[
                  const Icon(Icons.schedule_rounded, size: 20, color: _ink),
                  const SizedBox(width: 10),
                  const Text(
                    'Opening hours',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 14.5,
                      fontWeight: FontWeight.w800,
                      color: _ink,
                    ),
                  ),
                  const Spacer(),
                  const SizedBox(width: 8),
                  Flexible(
                    child: Text(
                      'Today · ${today.range}',
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      textAlign: TextAlign.end,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                  ),
                  Icon(
                    _hoursOpen
                        ? Icons.keyboard_arrow_up_rounded
                        : Icons.keyboard_arrow_down_rounded,
                    size: 20,
                    color: _muted,
                  ),
                ],
              ),
            ),
          ),
          if (_hoursOpen)
            Padding(
              padding: const EdgeInsets.fromLTRB(14, 0, 14, 6),
              child: Column(
                children: <Widget>[
                  const Divider(height: 1, color: _hairline),
                  for (final _Hours h in _hours)
                    Padding(
                      padding: const EdgeInsets.symmetric(vertical: 7),
                      child: Row(
                        children: <Widget>[
                          Text(
                            h.day,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w600,
                              color: _muted,
                            ),
                          ),
                          const Spacer(),
                          Text(
                            h.range,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w700,
                              color: _ink,
                            ),
                          ),
                        ],
                      ),
                    ),
                ],
              ),
            ),
        ],
      ),
    );
  }

The collapsed header shows a one-line summary: `final _Hours today = _hours[4]` hardcodes Friday as 'today' for the demo, and the `'Today · ${today.range}'` text sits inside a `Flexible` with `TextOverflow.ellipsis` so a long range truncates instead of overflowing the row on narrow phones. Tapping the `InkWell` flips `_hoursOpen`, which swaps the chevron between `keyboard_arrow_up_rounded` and `keyboard_arrow_down_rounded` and conditionally reveals the full list via `if (_hoursOpen)`. The expanded body draws a 1px `Divider` and then a row per day, each using `Spacer()` to push the muted day name and the bold range to opposite edges — weekend hours differ from weekdays, which is exactly why the full table earns its place.

About copy and the ranked Directions + Call bar

ecom_store_detail_screen.dart
  Widget _aboutCard() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: const <Widget>[
        Text(
          'About this store',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 16,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.2,
            color: _ink,
          ),
        ),
        SizedBox(height: 8),
        Text(
          'Our flagship SoHo space carries the full womenswear and menswear '
          'collections, a curated footwear floor and an in-house tailor for '
          'same-day alterations. Free pickup for online orders at the front '
          'desk, and a relaxed try-on lounge upstairs.',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13.5,
            fontWeight: FontWeight.w500,
            height: 1.5,
            color: _muted,
          ),
        ),
      ],
    );
  }

  Widget _bottomBar() {
    return Container(
      height: 88,
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
      child: Row(
        children: <Widget>[
          Expanded(
            child: GestureDetector(
              onTap: widget.onDirections,
              child: Container(
                height: 56,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  color: _brand,
                  borderRadius: BorderRadius.circular(9999),
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: const <Widget>[
                    Icon(Icons.directions_rounded,
                        size: 20, color: Colors.white),
                    SizedBox(width: 8),
                    Text(
                      'Directions',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15.5,
                        fontWeight: FontWeight.w800,
                        color: Colors.white,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
          const SizedBox(width: 12),
          GestureDetector(
            onTap: widget.onCall,
            child: Container(
              height: 56,
              width: 56,
              alignment: Alignment.center,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(9999),
                border: Border.all(color: _hairline),
              ),
              child: const Icon(Icons.call_outlined, size: 22, color: _ink),
            ),
          ),
        ],
      ),
    );
  }

`_aboutCard` is a fully `const` column — heading plus one 13.5px paragraph at `height: 1.5` — since nothing in it changes. The 88px bottom bar carries a top `_hairline` border to separate it from scrolling content. Its two actions are deliberately unequal: Directions is a full-width `_brand` pill (`borderRadius: BorderRadius.circular(9999)`) inside an `Expanded`, because getting to the store is the reason this page exists; Call is a 56×56 outlined circle holding only an icon. Giving both equal weight would force a choice, and a phone call is the fallback, not the goal. Both are plain `GestureDetector`s wired straight to `widget.onDirections` and `widget.onCall`.

Value classes and the two painters: star geometry and the mini-map

ecom_store_detail_screen.dart
class _Service {
  const _Service(this.icon, this.label);
  final IconData icon;
  final String label;
}

class _Hours {
  const _Hours(this.day, this.range);
  final String day;
  final String range;
}

/// A single filled rating star (Rausch-red).
class _StarPainter extends CustomPainter {
  const _StarPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final double cx = size.width / 2;
    final double cy = size.height / 2;
    final double outer = size.width / 2;
    final double inner = size.width / 4.4;
    final Path p = Path();
    const int points = 5;
    const double step = 3.141592653589793 / points;
    double rot = -3.141592653589793 / 2;
    for (int i = 0; i < points; i++) {
      p.lineTo(cx + outer * _cos(rot), cy + outer * _sin(rot));
      rot += step;
      p.lineTo(cx + inner * _cos(rot), cy + inner * _sin(rot));
      rot += step;
    }
    p.close();
    canvas.drawPath(p, Paint()..color = const Color(0xFFFF385C));
  }

  // Lightweight trig (kept inline so the painter needs no import).
  static double _cos(double x) => _sin(x + 1.5707963267948966);
  static double _sin(double x) {
    x %= 6.283185307179586;
    if (x > 3.141592653589793) x -= 6.283185307179586;
    final double x2 = x * x;
    return x * (1 - x2 / 6 + x2 * x2 / 120 - x2 * x2 * x2 / 5040);
  }

  @override
  bool shouldRepaint(_StarPainter old) => false;
}

/// A stylised painted mini-map: tinted land, street grid, and a centred brand
/// pin marking the store location.
class _MiniMapPainter extends CustomPainter {
  const _MiniMapPainter();

  static const Color _land = Color(0xFFEDEFF1);
  static const Color _road = Color(0xFFFFFFFF);
  static const Color _park = Color(0xFFD9EAD9);
  static const Color _brand = Color(0xFFFF385C);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Offset.zero & size, Paint()..color = _land);

    // Park patch.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(size.width * 0.04, size.height * 0.52,
            size.width * 0.26, size.height * 0.36),
        const Radius.circular(8),
      ),
      Paint()..color = _park,
    );

    final Paint road = Paint()
      ..color = _road
      ..strokeWidth = 7;
    for (double x = size.width * 0.2; x < size.width; x += size.width * 0.22) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), road);
    }
    for (double y = size.height * 0.28;
        y < size.height;
        y += size.height * 0.34) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), road);
    }

    // Centre pin.
    final Offset c = Offset(size.width * 0.5, size.height * 0.46);
    canvas.drawCircle(
        c, 18, Paint()..color = _brand.withValues(alpha: 0.16));
    final Path tail = Path()
      ..moveTo(c.dx - 6, c.dy + 4)
      ..lineTo(c.dx + 6, c.dy + 4)
      ..lineTo(c.dx, c.dy + 18)
      ..close();
    canvas.drawPath(tail, Paint()..color = _brand);
    canvas.drawCircle(c, 10, Paint()..color = _brand);
    canvas.drawCircle(c, 3.6, Paint()..color = Colors.white);
  }

  @override
  bool shouldRepaint(_MiniMapPainter old) => false;
}

`_Service` and `_Hours` are two-field const value classes — just enough structure to keep the data lists typed. `_StarPainter` walks five points around a circle, alternating between an outer radius of `size.width / 2` and an inner one of `size.width / 4.4`, starting at `rot = -π/2` so a point faces straight up; the quirky part is that it ships its own `_sin`/`_cos` as a short Taylor-series approximation purely to avoid importing `dart:math` for a 13px glyph. `_MiniMapPainter` fakes a map in four passes: a grey `_land` fill, a rounded green park patch, a grid of 7px white road lines stepped at fractions of the size (`x += size.width * 0.22`), and a centred pin built from a 16%-alpha halo, a triangular tail `Path`, a 10px brand circle and a 3.6px white core. Both painters return `false` from `shouldRepaint` because nothing they draw depends on state.

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 — Store detail.
///
/// A single physical store page: a swipeable photo carousel (bundled webp +
/// painted dots), name + open-now status + rating, an address card with a
/// painted mini-map preview, a services row (pickup / try-on / alterations /
/// returns), a weekly hours accordion, and a pinned bottom bar (Directions +
/// Call).
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea. Painter-only graphics
/// (mini-map, rating star). Reused bundled webp. Exposes callbacks only.
class EcomStoreDetailScreen extends StatefulWidget {
  const EcomStoreDetailScreen({
    super.key,
    this.onBack,
    this.onDirections,
    this.onCall,
  });

  final VoidCallback? onBack;
  final VoidCallback? onDirections;
  final VoidCallback? onCall;

  @override
  State<EcomStoreDetailScreen> createState() => _EcomStoreDetailScreenState();
}

class _EcomStoreDetailScreenState extends State<EcomStoreDetailScreen> {
  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 _hairline = Color(0xFFEBEBEB);
  static const Color _success = Color(0xFF2E9E5B);

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

  static const List<String> _photos = <String>[
    '$_dir/p02.webp',
    '$_dir/p03.webp',
    '$_dir/p21.webp',
  ];

  static const List<_Service> _services = <_Service>[
    _Service(Icons.shopping_bag_outlined, 'Pickup'),
    _Service(Icons.checkroom_outlined, 'Try-on'),
    _Service(Icons.content_cut_rounded, 'Alterations'),
    _Service(Icons.assignment_return_outlined, 'Returns'),
  ];

  static const List<_Hours> _hours = <_Hours>[
    _Hours('Monday', '10:00 AM – 9:00 PM'),
    _Hours('Tuesday', '10:00 AM – 9:00 PM'),
    _Hours('Wednesday', '10:00 AM – 9:00 PM'),
    _Hours('Thursday', '10:00 AM – 9:00 PM'),
    _Hours('Friday', '10:00 AM – 10:00 PM'),
    _Hours('Saturday', '9:00 AM – 10:00 PM'),
    _Hours('Sunday', '11:00 AM – 7:00 PM'),
  ];

  int _photo = 0;
  bool _hoursOpen = false;

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          top: false,
          child: Column(
            children: <Widget>[
              Expanded(
                child: ListView(
                  padding: EdgeInsets.zero,
                  children: <Widget>[
                    _gallery(),
                    Padding(
                      padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          _title(),
                          const SizedBox(height: 16),
                          _addressCard(),
                          const SizedBox(height: 18),
                          _servicesRow(),
                          const SizedBox(height: 18),
                          _hoursCard(),
                          const SizedBox(height: 18),
                          _aboutCard(),
                        ],
                      ),
                    ),
                    const SizedBox(height: 16),
                  ],
                ),
              ),
              _bottomBar(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _gallery() {
    return SizedBox(
      height: 280,
      child: Stack(
        children: <Widget>[
          PageView.builder(
            onPageChanged: (int i) => setState(() => _photo = i),
            itemCount: _photos.length,
            itemBuilder: (BuildContext _, int i) =>
                Image.asset(_photos[i], fit: BoxFit.cover),
          ),
          Positioned(
            top: MediaQuery.of(context).padding.top + 6,
            left: 8,
            child: Material(
              color: Colors.white.withValues(alpha: 0.92),
              shape: const CircleBorder(),
              child: InkWell(
                customBorder: const CircleBorder(),
                onTap: widget.onBack,
                child: const Padding(
                  padding: EdgeInsets.all(8),
                  child: Icon(Icons.arrow_back_rounded, size: 20, color: _ink),
                ),
              ),
            ),
          ),
          Positioned(
            bottom: 12,
            left: 0,
            right: 0,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                for (int i = 0; i < _photos.length; i++)
                  AnimatedContainer(
                    duration: const Duration(milliseconds: 200),
                    margin: const EdgeInsets.symmetric(horizontal: 3),
                    width: i == _photo ? 20 : 7,
                    height: 7,
                    decoration: BoxDecoration(
                      color: i == _photo
                          ? Colors.white
                          : Colors.white.withValues(alpha: 0.6),
                      borderRadius: BorderRadius.circular(4),
                    ),
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _title() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        const Text(
          'StyleCart · SoHo Flagship',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 22,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.4,
            color: _ink,
          ),
        ),
        const SizedBox(height: 6),
        Row(
          children: <Widget>[
            CustomPaint(size: const Size(13, 13), painter: _StarPainter()),
            const SizedBox(width: 4),
            const Text(
              '4.8',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 13,
                fontWeight: FontWeight.w800,
                color: _ink,
              ),
            ),
            const SizedBox(width: 4),
            const Text(
              '(1,204)',
              style: TextStyle(
                fontFamily: _font,
                fontSize: 12.5,
                fontWeight: FontWeight.w600,
                color: _muted,
              ),
            ),
            const SizedBox(width: 10),
            Container(width: 3, height: 3, decoration: const BoxDecoration(
                color: _faintDot, shape: BoxShape.circle)),
            const SizedBox(width: 10),
            Container(
              padding:
                  const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
              decoration: BoxDecoration(
                color: _success.withValues(alpha: 0.12),
                borderRadius: BorderRadius.circular(7),
              ),
              child: const Text(
                'Open until 9 PM',
                style: TextStyle(
                  fontFamily: _font,
                  fontSize: 11.5,
                  fontWeight: FontWeight.w800,
                  color: _success,
                ),
              ),
            ),
          ],
        ),
      ],
    );
  }

  static const Color _faintDot = Color(0xFFC1C1C1);

  Widget _addressCard() {
    return Container(
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          ClipRRect(
            borderRadius:
                const BorderRadius.vertical(top: Radius.circular(16)),
            child: CustomPaint(
              painter: _MiniMapPainter(),
              child: const SizedBox(height: 110, width: double.infinity),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(14),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Icon(Icons.place_rounded, size: 20, color: _brand),
                const SizedBox(width: 10),
                const Expanded(
                  child: Text(
                    '112 Spring Street, SoHo\nNew York, NY 10012',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 13.5,
                      fontWeight: FontWeight.w600,
                      height: 1.4,
                      color: _ink,
                    ),
                  ),
                ),
                const Text(
                  '0.6 mi',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w800,
                    color: _muted,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _servicesRow() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        const Text(
          'In-store services',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 16,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.2,
            color: _ink,
          ),
        ),
        const SizedBox(height: 12),
        Row(
          children: <Widget>[
            for (final _Service s in _services) ...<Widget>[
              Expanded(
                child: Column(
                  children: <Widget>[
                    Container(
                      width: 52,
                      height: 52,
                      decoration: BoxDecoration(
                        color: _surface,
                        borderRadius: BorderRadius.circular(14),
                      ),
                      child: Icon(s.icon, size: 22, color: _ink),
                    ),
                    const SizedBox(height: 6),
                    Text(
                      s.label,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 11.5,
                        fontWeight: FontWeight.w700,
                        color: _muted,
                      ),
                    ),
                  ],
                ),
              ),
            ],
          ],
        ),
      ],
    );
  }

  Widget _hoursCard() {
    final _Hours today = _hours[4]; // pretend "today" is Friday for the summary
    return Container(
      decoration: BoxDecoration(
        color: _canvas,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: _hairline),
      ),
      child: Column(
        children: <Widget>[
          InkWell(
            onTap: () => setState(() => _hoursOpen = !_hoursOpen),
            borderRadius: BorderRadius.circular(16),
            child: Padding(
              padding: const EdgeInsets.all(14),
              child: Row(
                children: <Widget>[
                  const Icon(Icons.schedule_rounded, size: 20, color: _ink),
                  const SizedBox(width: 10),
                  const Text(
                    'Opening hours',
                    style: TextStyle(
                      fontFamily: _font,
                      fontSize: 14.5,
                      fontWeight: FontWeight.w800,
                      color: _ink,
                    ),
                  ),
                  const Spacer(),
                  const SizedBox(width: 8),
                  Flexible(
                    child: Text(
                      'Today · ${today.range}',
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      textAlign: TextAlign.end,
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 12,
                        fontWeight: FontWeight.w600,
                        color: _muted,
                      ),
                    ),
                  ),
                  Icon(
                    _hoursOpen
                        ? Icons.keyboard_arrow_up_rounded
                        : Icons.keyboard_arrow_down_rounded,
                    size: 20,
                    color: _muted,
                  ),
                ],
              ),
            ),
          ),
          if (_hoursOpen)
            Padding(
              padding: const EdgeInsets.fromLTRB(14, 0, 14, 6),
              child: Column(
                children: <Widget>[
                  const Divider(height: 1, color: _hairline),
                  for (final _Hours h in _hours)
                    Padding(
                      padding: const EdgeInsets.symmetric(vertical: 7),
                      child: Row(
                        children: <Widget>[
                          Text(
                            h.day,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w600,
                              color: _muted,
                            ),
                          ),
                          const Spacer(),
                          Text(
                            h.range,
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 13,
                              fontWeight: FontWeight.w700,
                              color: _ink,
                            ),
                          ),
                        ],
                      ),
                    ),
                ],
              ),
            ),
        ],
      ),
    );
  }

  Widget _aboutCard() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: const <Widget>[
        Text(
          'About this store',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 16,
            fontWeight: FontWeight.w800,
            letterSpacing: -0.2,
            color: _ink,
          ),
        ),
        SizedBox(height: 8),
        Text(
          'Our flagship SoHo space carries the full womenswear and menswear '
          'collections, a curated footwear floor and an in-house tailor for '
          'same-day alterations. Free pickup for online orders at the front '
          'desk, and a relaxed try-on lounge upstairs.',
          style: TextStyle(
            fontFamily: _font,
            fontSize: 13.5,
            fontWeight: FontWeight.w500,
            height: 1.5,
            color: _muted,
          ),
        ),
      ],
    );
  }

  Widget _bottomBar() {
    return Container(
      height: 88,
      decoration: const BoxDecoration(
        color: _canvas,
        border: Border(top: BorderSide(color: _hairline)),
      ),
      padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
      child: Row(
        children: <Widget>[
          Expanded(
            child: GestureDetector(
              onTap: widget.onDirections,
              child: Container(
                height: 56,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  color: _brand,
                  borderRadius: BorderRadius.circular(9999),
                ),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: const <Widget>[
                    Icon(Icons.directions_rounded,
                        size: 20, color: Colors.white),
                    SizedBox(width: 8),
                    Text(
                      'Directions',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 15.5,
                        fontWeight: FontWeight.w800,
                        color: Colors.white,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
          const SizedBox(width: 12),
          GestureDetector(
            onTap: widget.onCall,
            child: Container(
              height: 56,
              width: 56,
              alignment: Alignment.center,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(9999),
                border: Border.all(color: _hairline),
              ),
              child: const Icon(Icons.call_outlined, size: 22, color: _ink),
            ),
          ),
        ],
      ),
    );
  }
}

class _Service {
  const _Service(this.icon, this.label);
  final IconData icon;
  final String label;
}

class _Hours {
  const _Hours(this.day, this.range);
  final String day;
  final String range;
}

/// A single filled rating star (Rausch-red).
class _StarPainter extends CustomPainter {
  const _StarPainter();

  @override
  void paint(Canvas canvas, Size size) {
    final double cx = size.width / 2;
    final double cy = size.height / 2;
    final double outer = size.width / 2;
    final double inner = size.width / 4.4;
    final Path p = Path();
    const int points = 5;
    const double step = 3.141592653589793 / points;
    double rot = -3.141592653589793 / 2;
    for (int i = 0; i < points; i++) {
      p.lineTo(cx + outer * _cos(rot), cy + outer * _sin(rot));
      rot += step;
      p.lineTo(cx + inner * _cos(rot), cy + inner * _sin(rot));
      rot += step;
    }
    p.close();
    canvas.drawPath(p, Paint()..color = const Color(0xFFFF385C));
  }

  // Lightweight trig (kept inline so the painter needs no import).
  static double _cos(double x) => _sin(x + 1.5707963267948966);
  static double _sin(double x) {
    x %= 6.283185307179586;
    if (x > 3.141592653589793) x -= 6.283185307179586;
    final double x2 = x * x;
    return x * (1 - x2 / 6 + x2 * x2 / 120 - x2 * x2 * x2 / 5040);
  }

  @override
  bool shouldRepaint(_StarPainter old) => false;
}

/// A stylised painted mini-map: tinted land, street grid, and a centred brand
/// pin marking the store location.
class _MiniMapPainter extends CustomPainter {
  const _MiniMapPainter();

  static const Color _land = Color(0xFFEDEFF1);
  static const Color _road = Color(0xFFFFFFFF);
  static const Color _park = Color(0xFFD9EAD9);
  static const Color _brand = Color(0xFFFF385C);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Offset.zero & size, Paint()..color = _land);

    // Park patch.
    canvas.drawRRect(
      RRect.fromRectAndRadius(
        Rect.fromLTWH(size.width * 0.04, size.height * 0.52,
            size.width * 0.26, size.height * 0.36),
        const Radius.circular(8),
      ),
      Paint()..color = _park,
    );

    final Paint road = Paint()
      ..color = _road
      ..strokeWidth = 7;
    for (double x = size.width * 0.2; x < size.width; x += size.width * 0.22) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), road);
    }
    for (double y = size.height * 0.28;
        y < size.height;
        y += size.height * 0.34) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), road);
    }

    // Centre pin.
    final Offset c = Offset(size.width * 0.5, size.height * 0.46);
    canvas.drawCircle(
        c, 18, Paint()..color = _brand.withValues(alpha: 0.16));
    final Path tail = Path()
      ..moveTo(c.dx - 6, c.dy + 4)
      ..lineTo(c.dx + 6, c.dy + 4)
      ..lineTo(c.dx, c.dy + 18)
      ..close();
    canvas.drawPath(tail, Paint()..color = _brand);
    canvas.drawCircle(c, 10, Paint()..color = _brand);
    canvas.drawCircle(c, 3.6, Paint()..color = Colors.white);
  }

  @override
  bool shouldRepaint(_MiniMapPainter old) => false;
}

Plus bundled 8 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-store-detail

2. AI agent (MCP)

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

FAQ

Is this store detail screen free to use in commercial apps?

Yes. FlutterKit screens are free to use, including commercially — drop the store detail screen, both painters and the bundled photos into a shipping retail or click-and-collect app with no attribution and no licence fee.

Do I need any packages or fonts to run this screen?

No packages at all — the only import is `package:flutter/material.dart`; even the mini-map and rating star are CustomPainters, and the star ships its own inline sine approximation to avoid `dart:math`. Typography is Manrope, bundled as an asset font and referenced via `fontFamily: 'Manrope'`, so there is no google_fonts dependency and no network fetch. The three carousel photos are bundled webp assets.

Which Flutter version does this screen need?

Flutter 3.27 or newer, because the back button, badge tint and pin halo all use `Color.withValues(alpha: ...)`. On an older SDK, replace each call with `withOpacity(...)` — for example `Colors.white.withOpacity(0.92)`. The constructor also uses `super.key`, which needs Dart 2.17+ (Flutter 3.0+), already covered by the 3.27 floor.

How do I swap the painted mini-map for a real map?

Replace the `CustomPaint(painter: _MiniMapPainter())` inside the address card's `ClipRRect` with a `google_maps_flutter` (lite mode) or Mapbox widget at the same 110px height — the rounded clipping and card border stay as they are. Keeping `_MiniMapPainter` around is still worthwhile as a zero-latency placeholder while tiles load or when the device is offline.

How do I make the 'Open until 9 PM' badge and the 'Today' summary real?

Both are demo constants: the badge text is a literal and `_hoursCard` picks `_hours[4]` (Friday) as today. Index the list with `DateTime.now().weekday - 1` instead, parse the day's range into open and close times, and compare against the current time — then render the green `_success` badge when open and a muted 'Closed · opens 10 AM' variant otherwise, since the tinted-pill recipe works for any status colour.

Related screens