E-commerce26 views

How to Build an E-commerce Home Feed in Flutter (Full Code + Preview)

This is the whole shopping app shell in one file: a greeting header with a location chip and badged action buttons, a swipeable promo carousel with animated worm dots, a horizontal category quick-row, a dark flash-sale strip with countdown chips, three horizontal product rails, and a 5-tab bottom nav. The part worth studying is how the rails work — one `_rail()` helper and one `_ProductCard` render all three sections from three plain lists, and each card computes its own discount percentage and wishlist state.

Shopping Home Feed — E-commerce Flutter UI screen
Live preview — Shopping Home Feed, built in pure Flutter.

Watch the Flutter UI walkthrough

A short screen recording of Shopping Home Feed running, if you'd rather see it before you read the code. The written tutorial below covers everything in it.

Can't see the video? Watch it on YouTube.

What you'll build

  • A `PageView` promo carousel at `viewportFraction: 0.9` so the next banner peeks in from the edge
  • Animated 'worm' page dots that stretch from 6px to 20px when their banner becomes active
  • One reusable rail builder that renders 'New arrivals', 'Trending now', and 'Picked for you' from three lists
  • Product cards that derive their `-25%` badge from price vs. was, and hold their own wishlist heart state
  • A dark gradient flash-sale strip with 02:45:30 countdown chips, and a 5-tab bottom nav with filled/outlined icon pairs

Step-by-step build

1

Create the file

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

Typed callbacks and the hard-coded data model

ecom_home_feed_screen.dart
class EcomHomeFeedScreen extends StatefulWidget {
  const EcomHomeFeedScreen({
    super.key,
    this.name = 'Jordan',
    this.location = 'Brooklyn, NY',
    this.onTab,
    this.onSearch,
    this.onCart,
    this.onNotifications,
    this.onShopHub,
    this.onBanner,
    this.onSeeAll,
    this.onProduct,
    this.onCategory,
    this.onFlashSale,
  });

  final String name;
  final String location;

  /// Bottom-nav tab tapped (0 Home · 1 Categories · 2 Cart · 3 Wishlist · 4 Profile).
  final ValueChanged<int>? onTab;
  final VoidCallback? onSearch;
  final VoidCallback? onCart;
  final VoidCallback? onNotifications;
  final VoidCallback? onShopHub;

  /// Hero banner tapped (passes its campaign label).
  final ValueChanged<String>? onBanner;

  /// "See all" tapped on a rail (passes the section key: new/trending/foryou).
  final ValueChanged<String>? onSeeAll;

  /// A product card tapped (passes its title).
  final ValueChanged<String>? onProduct;

  /// A category chip tapped (passes its label).
  final ValueChanged<String>? onCategory;
  final VoidCallback? onFlashSale;

  @override
  State<EcomHomeFeedScreen> createState() => _EcomHomeFeedScreenState();
}

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

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

  final PageController _banners = PageController(viewportFraction: 0.9);
  int _bannerIndex = 0;

  static const List<_Banner> _promos = <_Banner>[
    _Banner('Summer Edit', 'Up to 50% off linen & cotton', 'images/p09.webp',
        Color(0xFFFF385C)),
    _Banner('New Season Drop', 'Fresh fits, just landed', 'images/p10.webp',
        Color(0xFF460479)),
  ];

  static const List<_Cat> _cats = <_Cat>[
    _Cat('Women', Icons.woman_rounded),
    _Cat('Men', Icons.man_rounded),
    _Cat('Shoes', Icons.ice_skating_rounded),
    _Cat('Bags', Icons.shopping_bag_outlined),
    _Cat('Beauty', Icons.spa_outlined),
    _Cat('Home', Icons.chair_outlined),
  ];

  static const List<_Product> _newIn = <_Product>[
    _Product('Oversized blazer', 'Atelier', 128, 0, 4.8, 'images/p04.webp'),
    _Product('Relaxed denim', 'Northbound', 89, 0, 4.6, 'images/p02.webp'),
    _Product('Ribbed knit top', 'Maison', 54, 0, 4.9, 'images/p06.webp'),
    _Product('Tailored coat', 'Atelier', 210, 0, 4.7, 'images/p05.webp'),
  ];

  static const List<_Product> _trending = <_Product>[
    _Product('Court sneakers', 'Stride', 95, 120, 4.8, 'images/p08.webp'),
    _Product('Leather tote', 'Maison', 165, 0, 4.7, 'images/p07.webp'),
    _Product('Silk slip dress', 'Aria', 138, 175, 4.9, 'images/p01.webp'),
    _Product('Wool overshirt', 'Northbound', 112, 0, 4.5, 'images/p03.webp'),
  ];

  static const List<_Product> _forYou = <_Product>[
    _Product('Pleated midi skirt', 'Aria', 76, 0, 4.6, 'images/p06.webp'),
    _Product('Runner low-tops', 'Stride', 84, 110, 4.8, 'images/p08.webp'),
    _Product('Cropped trench', 'Atelier', 158, 0, 4.7, 'images/p05.webp'),
    _Product('Structured satchel', 'Maison', 142, 0, 4.9, 'images/p07.webp'),
  ];

  @override
  void dispose() {
    _banners.dispose();
    super.dispose();
  }

  void _open(String t) => widget.onProduct?.call(t);

Every interactive area reports through a callback rather than navigating itself, and the ones that need context are typed: `onTab` is `ValueChanged<int>` (0 Home … 4 Profile), while `onBanner`, `onSeeAll`, `onProduct`, and `onCategory` are `ValueChanged<String>` carrying the campaign title, section key, product title, or category label. Below the tokens sit the sample data as `static const` lists of the small value classes defined near the bottom of the file — two `_Banner`s, six `_Cat`s, and three four-item `_Product` lists. Note `_Product.was` is documented as `0 = no markdown`, a sentinel the card later uses to decide whether to draw a sale badge. `_dir` centralises the image folder path so every `Image.asset` call reads from one constant.

The shell: fixed header, scrolling body, pinned nav

ecom_home_feed_screen.dart
  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          bottom: false,
          child: Column(
            children: <Widget>[
              _header(),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.only(bottom: 20),
                  children: <Widget>[
                    const SizedBox(height: 4),
                    _bannerCarousel(),
                    const SizedBox(height: 18),
                    _categoryRow(),
                    const SizedBox(height: 20),
                    _flashSale(),
                    const SizedBox(height: 22),
                    _rail('New arrivals', 'new', _newIn),
                    const SizedBox(height: 22),
                    _rail('Trending now', 'trending', _trending),
                    const SizedBox(height: 22),
                    _rail('Picked for you', 'foryou', _forYou),
                  ],
                ),
              ),
            ],
          ),
        ),
        bottomNavigationBar: _NavBar(active: 0, onTab: widget.onTab),
      ),
    );
  }

`SafeArea(bottom: false)` is intentional — the top inset is respected but the bottom is handed to `_NavBar`, which applies its own `SafeArea(top: false)` so the nav background paints all the way through the home-indicator area instead of leaving a white gap. The header sits outside the scroll view, so the greeting and search bar stay put while `Expanded(child: ListView(...))` scrolls the sections beneath. Each section is a method call — `_bannerCarousel()`, `_categoryRow()`, `_flashSale()`, then three `_rail(...)` calls differing only by title, key, and list — which is what keeps a 900-line screen readable.

Greeting header, search bar, and badged buttons

ecom_home_feed_screen.dart
  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
      child: Column(
        children: <Widget>[
          Row(
            children: <Widget>[
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      'Hi ${widget.name}',
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 20,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.4,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 2),
                    Row(
                      children: <Widget>[
                        const Icon(Icons.place_outlined,
                            size: 14, color: _brand),
                        const SizedBox(width: 3),
                        Text(
                          widget.location,
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              _IconButton(
                icon: Icons.notifications_none_rounded,
                onTap: widget.onNotifications,
                badge: true,
              ),
              const SizedBox(width: 10),
              _IconButton(
                icon: Icons.shopping_bag_outlined,
                onTap: widget.onCart,
                badge: true,
              ),
            ],
          ),
          const SizedBox(height: 14),
          Row(
            children: <Widget>[
              Expanded(
                child: GestureDetector(
                  onTap: widget.onSearch,
                  child: Container(
                    height: 48,
                    padding: const EdgeInsets.symmetric(horizontal: 14),
                    decoration: BoxDecoration(
                      color: _surface,
                      borderRadius: BorderRadius.circular(14),
                      border: Border.all(color: _hairline),
                    ),
                    child: const Row(
                      children: <Widget>[
                        Icon(Icons.search_rounded, size: 20, color: _muted),
                        SizedBox(width: 10),
                        Text(
                          'Search for items, brands…',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 14.5,
                            fontWeight: FontWeight.w500,
                            color: _faint,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 10),
              GestureDetector(
                onTap: widget.onShopHub,
                child: Container(
                  width: 48,
                  height: 48,
                  decoration: BoxDecoration(
                    color: _brand,
                    borderRadius: BorderRadius.circular(14),
                  ),
                  child: const Icon(Icons.grid_view_rounded,
                      size: 20, color: _canvas),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

The greeting row wraps the name/location `Column` in `Expanded` so the two `_IconButton`s are pushed right and the text truncates rather than overflowing. The location line pairs a 14px `place_outlined` icon in brand red with 13px grey text — a tiny coloured icon next to muted text is what makes it read as metadata, not a heading. The search bar is *not* a `TextField`: it's a `GestureDetector` around a styled `Container` with placeholder text, which is the right call when tapping should push a dedicated search route. Next to it, a 48×48 brand-red tile with `grid_view_rounded` opens the shop hub. `_IconButton` uses `Stack(clipBehavior: Clip.none)` so its 4-radius red `CircleAvatar` badge can sit at the tile's corner without being clipped.

The banner carousel and animated dots

ecom_home_feed_screen.dart
  Widget _bannerCarousel() {
    return Column(
      children: <Widget>[
        SizedBox(
          height: 150,
          child: PageView.builder(
            controller: _banners,
            itemCount: _promos.length,
            onPageChanged: (int i) => setState(() => _bannerIndex = i),
            itemBuilder: (BuildContext context, int i) {
              final _Banner b = _promos[i];
              return Padding(
                padding: const EdgeInsets.symmetric(horizontal: 6),
                child: GestureDetector(
                  onTap: () => widget.onBanner?.call(b.title),
                  child: ClipRRect(
                    borderRadius: BorderRadius.circular(18),
                    child: Stack(
                      fit: StackFit.expand,
                      children: <Widget>[
                        Container(color: _imageBg),
                        Image.asset('$_dir/${b.asset.split('/').last}',
                            fit: BoxFit.cover),
                        DecoratedBox(
                          decoration: BoxDecoration(
                            gradient: LinearGradient(
                              begin: Alignment.centerLeft,
                              end: Alignment.centerRight,
                              colors: <Color>[
                                b.tint.withValues(alpha: 0.86),
                                b.tint.withValues(alpha: 0.18),
                              ],
                            ),
                          ),
                        ),
                        Padding(
                          padding: const EdgeInsets.all(18),
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: <Widget>[
                              Container(
                                padding: const EdgeInsets.symmetric(
                                    horizontal: 9, vertical: 4),
                                decoration: BoxDecoration(
                                  color: _canvas.withValues(alpha: 0.22),
                                  borderRadius: BorderRadius.circular(99),
                                ),
                                child: const Text(
                                  'LIMITED',
                                  style: TextStyle(
                                    fontFamily: _font,
                                    fontSize: 10,
                                    fontWeight: FontWeight.w800,
                                    letterSpacing: 0.6,
                                    color: _canvas,
                                  ),
                                ),
                              ),
                              const SizedBox(height: 10),
                              Text(
                                b.title,
                                style: const TextStyle(
                                  fontFamily: _font,
                                  fontSize: 22,
                                  fontWeight: FontWeight.w800,
                                  letterSpacing: -0.4,
                                  color: _canvas,
                                ),
                              ),
                              const SizedBox(height: 3),
                              Text(
                                b.subtitle,
                                style: TextStyle(
                                  fontFamily: _font,
                                  fontSize: 13,
                                  fontWeight: FontWeight.w600,
                                  color: _canvas.withValues(alpha: 0.92),
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              );
            },
          ),
        ),
        const SizedBox(height: 10),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: List<Widget>.generate(_promos.length, (int i) {
            final bool on = i == _bannerIndex;
            return AnimatedContainer(
              duration: const Duration(milliseconds: 200),
              margin: const EdgeInsets.symmetric(horizontal: 3),
              width: on ? 20 : 6,
              height: 6,
              decoration: BoxDecoration(
                color: on ? _brand : _faint,
                borderRadius: BorderRadius.circular(99),
              ),
            );
          }),
        ),
      ],
    );
  }

The `PageController` is created with `viewportFraction: 0.9`, so each page occupies 90% of the width and the neighbouring banner peeks in — the visual cue that tells users the strip is swipeable. Each banner is a `ClipRRect` at 18px wrapping a `Stack(fit: StackFit.expand)` layered in order: a placeholder `_imageBg` colour, the photo, a `LinearGradient` running left-to-right from the banner's own `tint` at 86% alpha down to 18%, then the text. That gradient is doing real work — it darkens only the left side, so the white 'LIMITED' pill and headline stay legible while the right side of the photo shows through. `onPageChanged` writes `_bannerIndex`, and the dots below use `AnimatedContainer` to tween `width` between 6 and 20 over 200ms, producing the stretching-worm indicator.

Category row and the flash-sale strip

ecom_home_feed_screen.dart
  Widget _categoryRow() {
    return SizedBox(
      height: 88,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20),
        itemCount: _cats.length,
        separatorBuilder: (_, _) => const SizedBox(width: 18),
        itemBuilder: (BuildContext context, int i) {
          final _Cat c = _cats[i];
          return GestureDetector(
            onTap: () => widget.onCategory?.call(c.label),
            child: Column(
              children: <Widget>[
                Container(
                  width: 58,
                  height: 58,
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(18),
                    border: Border.all(color: _hairline),
                  ),
                  child: Icon(c.icon, size: 26, color: _ink),
                ),
                const SizedBox(height: 7),
                Text(
                  c.label,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  // ---- Flash sale strip ---------------------------------------------------

  Widget _flashSale() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20),
      child: GestureDetector(
        onTap: widget.onFlashSale,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(18),
            gradient: const LinearGradient(
              colors: <Color>[Color(0xFF222222), Color(0xFF3A3A3A)],
            ),
          ),
          child: Row(
            children: <Widget>[
              const Icon(Icons.bolt_rounded, color: Color(0xFFF5A623), size: 26),
              const SizedBox(width: 12),
              const Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      'Flash sale',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w800,
                        color: _canvas,
                      ),
                    ),
                    SizedBox(height: 2),
                    Text(
                      'Ends in just a few hours',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        fontWeight: FontWeight.w600,
                        color: Color(0xFFBFBFBF),
                      ),
                    ),
                  ],
                ),
              ),
              Row(
                children: const <Widget>[
                  _TimeBox('02'),
                  _Colon(),
                  _TimeBox('45'),
                  _Colon(),
                  _TimeBox('30'),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }

The category quick-row is a horizontal `ListView.separated` locked to `height: 88` — a horizontal list must be given a bounded height by its parent or it will throw an unbounded-constraint error. Each entry is a 58×58 rounded tile with a Material icon over a label. The flash-sale strip inverts the palette: a dark `LinearGradient` from #222222 to #3A3A3A with an amber `bolt_rounded` icon, the copy inside `Expanded` so it takes leftover width, and three `_TimeBox` chips separated by amber `_Colon`s. Each `_TimeBox` is a 28×28 tile filled with `Colors.white.withValues(alpha: 0.16)` — a translucent white over the gradient rather than a fixed grey, so the chips pick up the strip's colour automatically.

One rail builder for three sections

ecom_home_feed_screen.dart
  Widget _rail(String title, String key, List<_Product> items) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: Row(
            children: <Widget>[
              Expanded(
                child: Text(
                  title,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 18,
                    fontWeight: FontWeight.w800,
                    letterSpacing: -0.3,
                    color: _ink,
                  ),
                ),
              ),
              GestureDetector(
                onTap: () => widget.onSeeAll?.call(key),
                child: const Text(
                  'See all',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 13.5,
                    fontWeight: FontWeight.w700,
                    color: _brand,
                  ),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        SizedBox(
          height: 256,
          child: ListView.separated(
            scrollDirection: Axis.horizontal,
            padding: const EdgeInsets.symmetric(horizontal: 20),
            itemCount: items.length,
            separatorBuilder: (_, _) => const SizedBox(width: 14),
            itemBuilder: (BuildContext context, int i) => _ProductCard(
              product: items[i],
              dir: _dir,
              onTap: () => _open(items[i].title),
            ),
          ),
        ),
      ],
    );
  }

`_rail` takes a display `title`, a `key` string, and the item list. The header row puts the title in `Expanded` and a brand-red 'See all' tap target after it, and that link reports `widget.onSeeAll?.call(key)` — which is why the section key ('new', 'trending', 'foryou') is passed separately from the human-readable title. The rail body is a `SizedBox(height: 256)` around a horizontal `ListView.separated` with a 14px `separatorBuilder`; using separators rather than per-item margins means no stray gap after the last card. Every item builds the same `_ProductCard`, so all three rails stay visually identical by construction.

The product card: derived sale badge and local wishlist

ecom_home_feed_screen.dart
class _ProductCard extends StatefulWidget {
  const _ProductCard({
    required this.product,
    required this.dir,
    required this.onTap,
  });
  final _Product product;
  final String dir;
  final VoidCallback onTap;

  @override
  State<_ProductCard> createState() => _ProductCardState();
}

class _ProductCardState extends State<_ProductCard> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _imageBg = Color(0xFFF5F5F5);

  bool _wish = false;

  @override
  Widget build(BuildContext context) {
    final _Product p = widget.product;
    final bool sale = p.was > p.price && p.was > 0;
    return GestureDetector(
      onTap: widget.onTap,
      child: SizedBox(
        width: 162,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Expanded(
              child: ClipRRect(
                borderRadius: BorderRadius.circular(16),
                child: Stack(
                  fit: StackFit.expand,
                  children: <Widget>[
                    Container(color: _imageBg),
                    Image.asset('${widget.dir}/${p.asset.split('/').last}',
                        fit: BoxFit.cover),
                    if (sale)
                      Positioned(
                        left: 8,
                        top: 8,
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                              horizontal: 8, vertical: 4),
                          decoration: BoxDecoration(
                            color: _brand,
                            borderRadius: BorderRadius.circular(8),
                          ),
                          child: Text(
                            '-${(100 * (p.was - p.price) / p.was).round()}%',
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 11,
                              fontWeight: FontWeight.w800,
                              color: _canvas,
                            ),
                          ),
                        ),
                      ),
                    Positioned(
                      right: 8,
                      top: 8,
                      child: GestureDetector(
                        onTap: () => setState(() => _wish = !_wish),
                        child: Container(
                          width: 30,
                          height: 30,
                          decoration: BoxDecoration(
                            color: _canvas.withValues(alpha: 0.92),
                            shape: BoxShape.circle,
                          ),
                          child: Icon(
                            _wish
                                ? Icons.favorite_rounded
                                : Icons.favorite_border_rounded,
                            size: 17,
                            color: _wish ? _brand : _ink,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 9),
            Text(
              p.brand.toUpperCase(),
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 10.5,
                fontWeight: FontWeight.w700,
                letterSpacing: 0.6,
                color: _muted,
              ),
            ),
            const SizedBox(height: 2),
            Text(
              p.title,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 14,
                fontWeight: FontWeight.w700,
                color: _ink,
              ),
            ),
            const SizedBox(height: 5),
            Row(
              children: <Widget>[
                Text(
                  '\$${p.price}',
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                if (sale) ...<Widget>[
                  const SizedBox(width: 6),
                  Text(
                    '\$${p.was}',
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                      color: _faint,
                      decoration: TextDecoration.lineThrough,
                    ),
                  ),
                ],
                const Spacer(),
                const Icon(Icons.star_rounded,
                    size: 14, color: Color(0xFFF5A623)),
                const SizedBox(width: 2),
                Text(
                  p.rating.toStringAsFixed(1),
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

`_ProductCard` is the only stateful piece below the screen, because each card owns one `_wish` boolean for its heart toggle. `sale` is derived, not stored: `p.was > p.price && p.was > 0`. When true, the badge text is computed inline as `'-${(100 * (p.was - p.price) / p.was).round()}%'`, so the discount can never disagree with the prices — change a number in the data list and the badge follows. The card is a fixed 162px wide with the image in `Expanded`, which lets the image absorb whatever height the text block doesn't use. Below the photo: the brand in `toUpperCase()` at 10.5px with `letterSpacing: 0.6` (the classic small-caps label treatment), the title clipped with `maxLines: 1` and `TextOverflow.ellipsis`, then a price row where the struck-through original price is spread in with collection-if and a `Spacer()` pushes the amber star rating to the right edge.

The 5-tab bottom navigation bar

ecom_home_feed_screen.dart
class _NavBar extends StatelessWidget {
  const _NavBar({required this.active, this.onTab});
  final int active;
  final ValueChanged<int>? onTab;

  static const List<_NavItem> _items = <_NavItem>[
    _NavItem('Home', Icons.home_rounded, Icons.home_outlined),
    _NavItem('Categories', Icons.grid_view_rounded, Icons.grid_view_outlined),
    _NavItem('Cart', Icons.shopping_bag_rounded, Icons.shopping_bag_outlined),
    _NavItem('Wishlist', Icons.favorite_rounded, Icons.favorite_border_rounded),
    _NavItem('Profile', Icons.person_rounded, Icons.person_outline_rounded),
  ];

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: Color(0xFFFFFFFF),
        border: Border(top: BorderSide(color: Color(0xFFEBEBEB))),
      ),
      child: SafeArea(
        top: false,
        child: SizedBox(
          height: 62,
          child: Row(
            children: List<Widget>.generate(_items.length, (int i) {
              final _NavItem it = _items[i];
              final bool on = i == active;
              return Expanded(
                child: GestureDetector(
                  behavior: HitTestBehavior.opaque,
                  onTap: () => onTab?.call(i),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Icon(
                        on ? it.active : it.inactive,
                        size: 24,
                        color: on
                            ? const Color(0xFFFF385C)
                            : const Color(0xFF6A6A6A),
                      ),
                      const SizedBox(height: 3),
                      Text(
                        it.label,
                        style: TextStyle(
                          fontFamily: 'Manrope',
                          fontSize: 10.5,
                          fontWeight: on ? FontWeight.w800 : FontWeight.w600,
                          color: on
                              ? const Color(0xFFFF385C)
                              : const Color(0xFF6A6A6A),
                        ),
                      ),
                    ],
                  ),
                ),
              );
            }),
          ),
        ),
      ),
    );
  }
}

class _NavItem {
  const _NavItem(this.label, this.active, this.inactive);
  final String label;
  final IconData active;
  final IconData inactive;
}

`_NavBar` is a hand-rolled bar rather than Flutter's `BottomNavigationBar`, which gives you exact control over height and typography. Each `_NavItem` stores both a filled and an outlined `IconData`, and the builder picks between them with `on ? it.active : it.inactive` while also swapping colour and font weight — three signals for the selected tab instead of just one. Wrapping each tab in `Expanded` divides the width evenly no matter how many items you add, and `HitTestBehavior.opaque` makes the whole 62px column tappable rather than just the icon and label glyphs.

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 — Home (Home tab) — the app shell.
///
/// Greeting + location chip + search bar + cart/notification actions; a swipeable
/// hero promo carousel, a category quick-row, a "Flash sale" strip, and three
/// horizontal product rails (New arrivals / Trending / For you). Carries the
/// 5-tab bottom navigation bar that frames the whole shopping experience.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp photos. Marks
/// (wishlist heart pulse, banner dots, nav icons) are CustomPainters / Material
/// icons — no emoji glyphs. Exposes callbacks only; the gallery wires nav.
class EcomHomeFeedScreen extends StatefulWidget {
  const EcomHomeFeedScreen({
    super.key,
    this.name = 'Jordan',
    this.location = 'Brooklyn, NY',
    this.onTab,
    this.onSearch,
    this.onCart,
    this.onNotifications,
    this.onShopHub,
    this.onBanner,
    this.onSeeAll,
    this.onProduct,
    this.onCategory,
    this.onFlashSale,
  });

  final String name;
  final String location;

  /// Bottom-nav tab tapped (0 Home · 1 Categories · 2 Cart · 3 Wishlist · 4 Profile).
  final ValueChanged<int>? onTab;
  final VoidCallback? onSearch;
  final VoidCallback? onCart;
  final VoidCallback? onNotifications;
  final VoidCallback? onShopHub;

  /// Hero banner tapped (passes its campaign label).
  final ValueChanged<String>? onBanner;

  /// "See all" tapped on a rail (passes the section key: new/trending/foryou).
  final ValueChanged<String>? onSeeAll;

  /// A product card tapped (passes its title).
  final ValueChanged<String>? onProduct;

  /// A category chip tapped (passes its label).
  final ValueChanged<String>? onCategory;
  final VoidCallback? onFlashSale;

  @override
  State<EcomHomeFeedScreen> createState() => _EcomHomeFeedScreenState();
}

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

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

  final PageController _banners = PageController(viewportFraction: 0.9);
  int _bannerIndex = 0;

  static const List<_Banner> _promos = <_Banner>[
    _Banner('Summer Edit', 'Up to 50% off linen & cotton', 'images/p09.webp',
        Color(0xFFFF385C)),
    _Banner('New Season Drop', 'Fresh fits, just landed', 'images/p10.webp',
        Color(0xFF460479)),
  ];

  static const List<_Cat> _cats = <_Cat>[
    _Cat('Women', Icons.woman_rounded),
    _Cat('Men', Icons.man_rounded),
    _Cat('Shoes', Icons.ice_skating_rounded),
    _Cat('Bags', Icons.shopping_bag_outlined),
    _Cat('Beauty', Icons.spa_outlined),
    _Cat('Home', Icons.chair_outlined),
  ];

  static const List<_Product> _newIn = <_Product>[
    _Product('Oversized blazer', 'Atelier', 128, 0, 4.8, 'images/p04.webp'),
    _Product('Relaxed denim', 'Northbound', 89, 0, 4.6, 'images/p02.webp'),
    _Product('Ribbed knit top', 'Maison', 54, 0, 4.9, 'images/p06.webp'),
    _Product('Tailored coat', 'Atelier', 210, 0, 4.7, 'images/p05.webp'),
  ];

  static const List<_Product> _trending = <_Product>[
    _Product('Court sneakers', 'Stride', 95, 120, 4.8, 'images/p08.webp'),
    _Product('Leather tote', 'Maison', 165, 0, 4.7, 'images/p07.webp'),
    _Product('Silk slip dress', 'Aria', 138, 175, 4.9, 'images/p01.webp'),
    _Product('Wool overshirt', 'Northbound', 112, 0, 4.5, 'images/p03.webp'),
  ];

  static const List<_Product> _forYou = <_Product>[
    _Product('Pleated midi skirt', 'Aria', 76, 0, 4.6, 'images/p06.webp'),
    _Product('Runner low-tops', 'Stride', 84, 110, 4.8, 'images/p08.webp'),
    _Product('Cropped trench', 'Atelier', 158, 0, 4.7, 'images/p05.webp'),
    _Product('Structured satchel', 'Maison', 142, 0, 4.9, 'images/p07.webp'),
  ];

  @override
  void dispose() {
    _banners.dispose();
    super.dispose();
  }

  void _open(String t) => widget.onProduct?.call(t);

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: ThemeData.light(useMaterial3: true),
      child: Scaffold(
        backgroundColor: _canvas,
        body: SafeArea(
          bottom: false,
          child: Column(
            children: <Widget>[
              _header(),
              Expanded(
                child: ListView(
                  padding: const EdgeInsets.only(bottom: 20),
                  children: <Widget>[
                    const SizedBox(height: 4),
                    _bannerCarousel(),
                    const SizedBox(height: 18),
                    _categoryRow(),
                    const SizedBox(height: 20),
                    _flashSale(),
                    const SizedBox(height: 22),
                    _rail('New arrivals', 'new', _newIn),
                    const SizedBox(height: 22),
                    _rail('Trending now', 'trending', _trending),
                    const SizedBox(height: 22),
                    _rail('Picked for you', 'foryou', _forYou),
                  ],
                ),
              ),
            ],
          ),
        ),
        bottomNavigationBar: _NavBar(active: 0, onTab: widget.onTab),
      ),
    );
  }

  // ---- Header -------------------------------------------------------------

  Widget _header() {
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 8, 20, 12),
      child: Column(
        children: <Widget>[
          Row(
            children: <Widget>[
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      'Hi ${widget.name}',
                      style: const TextStyle(
                        fontFamily: _font,
                        fontSize: 20,
                        fontWeight: FontWeight.w800,
                        letterSpacing: -0.4,
                        color: _ink,
                      ),
                    ),
                    const SizedBox(height: 2),
                    Row(
                      children: <Widget>[
                        const Icon(Icons.place_outlined,
                            size: 14, color: _brand),
                        const SizedBox(width: 3),
                        Text(
                          widget.location,
                          style: const TextStyle(
                            fontFamily: _font,
                            fontSize: 13,
                            fontWeight: FontWeight.w600,
                            color: _muted,
                          ),
                        ),
                      ],
                    ),
                  ],
                ),
              ),
              _IconButton(
                icon: Icons.notifications_none_rounded,
                onTap: widget.onNotifications,
                badge: true,
              ),
              const SizedBox(width: 10),
              _IconButton(
                icon: Icons.shopping_bag_outlined,
                onTap: widget.onCart,
                badge: true,
              ),
            ],
          ),
          const SizedBox(height: 14),
          Row(
            children: <Widget>[
              Expanded(
                child: GestureDetector(
                  onTap: widget.onSearch,
                  child: Container(
                    height: 48,
                    padding: const EdgeInsets.symmetric(horizontal: 14),
                    decoration: BoxDecoration(
                      color: _surface,
                      borderRadius: BorderRadius.circular(14),
                      border: Border.all(color: _hairline),
                    ),
                    child: const Row(
                      children: <Widget>[
                        Icon(Icons.search_rounded, size: 20, color: _muted),
                        SizedBox(width: 10),
                        Text(
                          'Search for items, brands…',
                          style: TextStyle(
                            fontFamily: _font,
                            fontSize: 14.5,
                            fontWeight: FontWeight.w500,
                            color: _faint,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
              const SizedBox(width: 10),
              GestureDetector(
                onTap: widget.onShopHub,
                child: Container(
                  width: 48,
                  height: 48,
                  decoration: BoxDecoration(
                    color: _brand,
                    borderRadius: BorderRadius.circular(14),
                  ),
                  child: const Icon(Icons.grid_view_rounded,
                      size: 20, color: _canvas),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  // ---- Banner carousel ----------------------------------------------------

  Widget _bannerCarousel() {
    return Column(
      children: <Widget>[
        SizedBox(
          height: 150,
          child: PageView.builder(
            controller: _banners,
            itemCount: _promos.length,
            onPageChanged: (int i) => setState(() => _bannerIndex = i),
            itemBuilder: (BuildContext context, int i) {
              final _Banner b = _promos[i];
              return Padding(
                padding: const EdgeInsets.symmetric(horizontal: 6),
                child: GestureDetector(
                  onTap: () => widget.onBanner?.call(b.title),
                  child: ClipRRect(
                    borderRadius: BorderRadius.circular(18),
                    child: Stack(
                      fit: StackFit.expand,
                      children: <Widget>[
                        Container(color: _imageBg),
                        Image.asset('$_dir/${b.asset.split('/').last}',
                            fit: BoxFit.cover),
                        DecoratedBox(
                          decoration: BoxDecoration(
                            gradient: LinearGradient(
                              begin: Alignment.centerLeft,
                              end: Alignment.centerRight,
                              colors: <Color>[
                                b.tint.withValues(alpha: 0.86),
                                b.tint.withValues(alpha: 0.18),
                              ],
                            ),
                          ),
                        ),
                        Padding(
                          padding: const EdgeInsets.all(18),
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: <Widget>[
                              Container(
                                padding: const EdgeInsets.symmetric(
                                    horizontal: 9, vertical: 4),
                                decoration: BoxDecoration(
                                  color: _canvas.withValues(alpha: 0.22),
                                  borderRadius: BorderRadius.circular(99),
                                ),
                                child: const Text(
                                  'LIMITED',
                                  style: TextStyle(
                                    fontFamily: _font,
                                    fontSize: 10,
                                    fontWeight: FontWeight.w800,
                                    letterSpacing: 0.6,
                                    color: _canvas,
                                  ),
                                ),
                              ),
                              const SizedBox(height: 10),
                              Text(
                                b.title,
                                style: const TextStyle(
                                  fontFamily: _font,
                                  fontSize: 22,
                                  fontWeight: FontWeight.w800,
                                  letterSpacing: -0.4,
                                  color: _canvas,
                                ),
                              ),
                              const SizedBox(height: 3),
                              Text(
                                b.subtitle,
                                style: TextStyle(
                                  fontFamily: _font,
                                  fontSize: 13,
                                  fontWeight: FontWeight.w600,
                                  color: _canvas.withValues(alpha: 0.92),
                                ),
                              ),
                            ],
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
              );
            },
          ),
        ),
        const SizedBox(height: 10),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: List<Widget>.generate(_promos.length, (int i) {
            final bool on = i == _bannerIndex;
            return AnimatedContainer(
              duration: const Duration(milliseconds: 200),
              margin: const EdgeInsets.symmetric(horizontal: 3),
              width: on ? 20 : 6,
              height: 6,
              decoration: BoxDecoration(
                color: on ? _brand : _faint,
                borderRadius: BorderRadius.circular(99),
              ),
            );
          }),
        ),
      ],
    );
  }

  // ---- Category quick-row -------------------------------------------------

  Widget _categoryRow() {
    return SizedBox(
      height: 88,
      child: ListView.separated(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 20),
        itemCount: _cats.length,
        separatorBuilder: (_, _) => const SizedBox(width: 18),
        itemBuilder: (BuildContext context, int i) {
          final _Cat c = _cats[i];
          return GestureDetector(
            onTap: () => widget.onCategory?.call(c.label),
            child: Column(
              children: <Widget>[
                Container(
                  width: 58,
                  height: 58,
                  decoration: BoxDecoration(
                    color: _surface,
                    borderRadius: BorderRadius.circular(18),
                    border: Border.all(color: _hairline),
                  ),
                  child: Icon(c.icon, size: 26, color: _ink),
                ),
                const SizedBox(height: 7),
                Text(
                  c.label,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  // ---- Flash sale strip ---------------------------------------------------

  Widget _flashSale() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20),
      child: GestureDetector(
        onTap: widget.onFlashSale,
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(18),
            gradient: const LinearGradient(
              colors: <Color>[Color(0xFF222222), Color(0xFF3A3A3A)],
            ),
          ),
          child: Row(
            children: <Widget>[
              const Icon(Icons.bolt_rounded, color: Color(0xFFF5A623), size: 26),
              const SizedBox(width: 12),
              const Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      'Flash sale',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 16,
                        fontWeight: FontWeight.w800,
                        color: _canvas,
                      ),
                    ),
                    SizedBox(height: 2),
                    Text(
                      'Ends in just a few hours',
                      style: TextStyle(
                        fontFamily: _font,
                        fontSize: 12.5,
                        fontWeight: FontWeight.w600,
                        color: Color(0xFFBFBFBF),
                      ),
                    ),
                  ],
                ),
              ),
              Row(
                children: const <Widget>[
                  _TimeBox('02'),
                  _Colon(),
                  _TimeBox('45'),
                  _Colon(),
                  _TimeBox('30'),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }

  // ---- Product rail -------------------------------------------------------

  Widget _rail(String title, String key, List<_Product> items) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20),
          child: Row(
            children: <Widget>[
              Expanded(
                child: Text(
                  title,
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 18,
                    fontWeight: FontWeight.w800,
                    letterSpacing: -0.3,
                    color: _ink,
                  ),
                ),
              ),
              GestureDetector(
                onTap: () => widget.onSeeAll?.call(key),
                child: const Text(
                  'See all',
                  style: TextStyle(
                    fontFamily: _font,
                    fontSize: 13.5,
                    fontWeight: FontWeight.w700,
                    color: _brand,
                  ),
                ),
              ),
            ],
          ),
        ),
        const SizedBox(height: 12),
        SizedBox(
          height: 256,
          child: ListView.separated(
            scrollDirection: Axis.horizontal,
            padding: const EdgeInsets.symmetric(horizontal: 20),
            itemCount: items.length,
            separatorBuilder: (_, _) => const SizedBox(width: 14),
            itemBuilder: (BuildContext context, int i) => _ProductCard(
              product: items[i],
              dir: _dir,
              onTap: () => _open(items[i].title),
            ),
          ),
        ),
      ],
    );
  }
}

// ---- Data ----------------------------------------------------------------

class _Banner {
  const _Banner(this.title, this.subtitle, this.asset, this.tint);
  final String title;
  final String subtitle;
  final String asset;
  final Color tint;
}

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

class _Product {
  const _Product(this.title, this.brand, this.price, this.was, this.rating,
      this.asset);
  final String title;
  final String brand;
  final int price;
  final int was; // 0 = no markdown
  final double rating;
  final String asset;
}

// ---- Product card (reused across rails) ----------------------------------

class _ProductCard extends StatefulWidget {
  const _ProductCard({
    required this.product,
    required this.dir,
    required this.onTap,
  });
  final _Product product;
  final String dir;
  final VoidCallback onTap;

  @override
  State<_ProductCard> createState() => _ProductCardState();
}

class _ProductCardState extends State<_ProductCard> {
  static const String _font = 'Manrope';
  static const Color _canvas = Color(0xFFFFFFFF);
  static const Color _ink = Color(0xFF222222);
  static const Color _muted = Color(0xFF6A6A6A);
  static const Color _faint = Color(0xFFC1C1C1);
  static const Color _brand = Color(0xFFFF385C);
  static const Color _imageBg = Color(0xFFF5F5F5);

  bool _wish = false;

  @override
  Widget build(BuildContext context) {
    final _Product p = widget.product;
    final bool sale = p.was > p.price && p.was > 0;
    return GestureDetector(
      onTap: widget.onTap,
      child: SizedBox(
        width: 162,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Expanded(
              child: ClipRRect(
                borderRadius: BorderRadius.circular(16),
                child: Stack(
                  fit: StackFit.expand,
                  children: <Widget>[
                    Container(color: _imageBg),
                    Image.asset('${widget.dir}/${p.asset.split('/').last}',
                        fit: BoxFit.cover),
                    if (sale)
                      Positioned(
                        left: 8,
                        top: 8,
                        child: Container(
                          padding: const EdgeInsets.symmetric(
                              horizontal: 8, vertical: 4),
                          decoration: BoxDecoration(
                            color: _brand,
                            borderRadius: BorderRadius.circular(8),
                          ),
                          child: Text(
                            '-${(100 * (p.was - p.price) / p.was).round()}%',
                            style: const TextStyle(
                              fontFamily: _font,
                              fontSize: 11,
                              fontWeight: FontWeight.w800,
                              color: _canvas,
                            ),
                          ),
                        ),
                      ),
                    Positioned(
                      right: 8,
                      top: 8,
                      child: GestureDetector(
                        onTap: () => setState(() => _wish = !_wish),
                        child: Container(
                          width: 30,
                          height: 30,
                          decoration: BoxDecoration(
                            color: _canvas.withValues(alpha: 0.92),
                            shape: BoxShape.circle,
                          ),
                          child: Icon(
                            _wish
                                ? Icons.favorite_rounded
                                : Icons.favorite_border_rounded,
                            size: 17,
                            color: _wish ? _brand : _ink,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 9),
            Text(
              p.brand.toUpperCase(),
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 10.5,
                fontWeight: FontWeight.w700,
                letterSpacing: 0.6,
                color: _muted,
              ),
            ),
            const SizedBox(height: 2),
            Text(
              p.title,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: const TextStyle(
                fontFamily: _font,
                fontSize: 14,
                fontWeight: FontWeight.w700,
                color: _ink,
              ),
            ),
            const SizedBox(height: 5),
            Row(
              children: <Widget>[
                Text(
                  '\$${p.price}',
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 14.5,
                    fontWeight: FontWeight.w800,
                    color: _ink,
                  ),
                ),
                if (sale) ...<Widget>[
                  const SizedBox(width: 6),
                  Text(
                    '\$${p.was}',
                    style: const TextStyle(
                      fontFamily: _font,
                      fontSize: 12,
                      fontWeight: FontWeight.w600,
                      color: _faint,
                      decoration: TextDecoration.lineThrough,
                    ),
                  ),
                ],
                const Spacer(),
                const Icon(Icons.star_rounded,
                    size: 14, color: Color(0xFFF5A623)),
                const SizedBox(width: 2),
                Text(
                  p.rating.toStringAsFixed(1),
                  style: const TextStyle(
                    fontFamily: _font,
                    fontSize: 12.5,
                    fontWeight: FontWeight.w700,
                    color: _ink,
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// ---- Header icon button --------------------------------------------------

class _IconButton extends StatelessWidget {
  const _IconButton({required this.icon, this.onTap, this.badge = false});
  final IconData icon;
  final VoidCallback? onTap;
  final bool badge;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Stack(
        clipBehavior: Clip.none,
        children: <Widget>[
          Container(
            width: 44,
            height: 44,
            decoration: BoxDecoration(
              color: const Color(0xFFF2F2F2),
              borderRadius: BorderRadius.circular(13),
              border: Border.all(color: const Color(0xFFEBEBEB)),
            ),
            child: Icon(icon, size: 21, color: const Color(0xFF222222)),
          ),
          if (badge)
            const Positioned(
              right: 9,
              top: 9,
              child: CircleAvatar(radius: 4, backgroundColor: Color(0xFFFF385C)),
            ),
        ],
      ),
    );
  }
}

// ---- Flash-sale countdown chips ------------------------------------------

class _TimeBox extends StatelessWidget {
  const _TimeBox(this.value);
  final String value;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 28,
      height: 28,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: Colors.white.withValues(alpha: 0.16),
        borderRadius: BorderRadius.circular(7),
      ),
      child: Text(
        value,
        style: const TextStyle(
          fontFamily: 'Manrope',
          fontSize: 13,
          fontWeight: FontWeight.w800,
          color: Colors.white,
        ),
      ),
    );
  }
}

class _Colon extends StatelessWidget {
  const _Colon();
  @override
  Widget build(BuildContext context) {
    return const Padding(
      padding: EdgeInsets.symmetric(horizontal: 3),
      child: Text(
        ':',
        style: TextStyle(
          fontFamily: 'Manrope',
          fontSize: 14,
          fontWeight: FontWeight.w800,
          color: Color(0xFFF5A623),
        ),
      ),
    );
  }
}

// ---- Bottom navigation bar (the app shell frame) -------------------------

class _NavBar extends StatelessWidget {
  const _NavBar({required this.active, this.onTab});
  final int active;
  final ValueChanged<int>? onTab;

  static const List<_NavItem> _items = <_NavItem>[
    _NavItem('Home', Icons.home_rounded, Icons.home_outlined),
    _NavItem('Categories', Icons.grid_view_rounded, Icons.grid_view_outlined),
    _NavItem('Cart', Icons.shopping_bag_rounded, Icons.shopping_bag_outlined),
    _NavItem('Wishlist', Icons.favorite_rounded, Icons.favorite_border_rounded),
    _NavItem('Profile', Icons.person_rounded, Icons.person_outline_rounded),
  ];

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: Color(0xFFFFFFFF),
        border: Border(top: BorderSide(color: Color(0xFFEBEBEB))),
      ),
      child: SafeArea(
        top: false,
        child: SizedBox(
          height: 62,
          child: Row(
            children: List<Widget>.generate(_items.length, (int i) {
              final _NavItem it = _items[i];
              final bool on = i == active;
              return Expanded(
                child: GestureDetector(
                  behavior: HitTestBehavior.opaque,
                  onTap: () => onTab?.call(i),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      Icon(
                        on ? it.active : it.inactive,
                        size: 24,
                        color: on
                            ? const Color(0xFFFF385C)
                            : const Color(0xFF6A6A6A),
                      ),
                      const SizedBox(height: 3),
                      Text(
                        it.label,
                        style: TextStyle(
                          fontFamily: 'Manrope',
                          fontSize: 10.5,
                          fontWeight: on ? FontWeight.w800 : FontWeight.w600,
                          color: on
                              ? const Color(0xFFFF385C)
                              : const Color(0xFF6A6A6A),
                        ),
                      ),
                    ],
                  ),
                ),
              );
            }),
          ),
        ),
      ),
    );
  }
}

class _NavItem {
  const _NavItem(this.label, this.active, this.inactive);
  final String label;
  final IconData active;
  final IconData inactive;
}

Plus bundled 15 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-home-feed

2. AI agent (MCP)

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

FAQ

Is this Flutter home feed screen free to use?

Yes. The entire Dart file on this page is free to use in personal and commercial apps. Copy it from the code block, run flutterkit add ecom-home-feed with the CLI, or install it through an AI agent over MCP.

Does it need a carousel or slider package?

No — the promo carousel is Flutter's built-in PageView.builder with a viewportFraction, and the dots are AnimatedContainers. The whole screen is pure Flutter on material.dart. It does ship bundled WebP product photos plus the Manrope font family, both registered in pubspec.yaml as shown in the dependencies step.

How do I swap in my own products and images?

Replace the entries in the _newIn, _trending, and _forYou lists — each _Product is (title, brand, price, was, rating, asset). Set was to 0 for anything not on sale and the discount badge disappears automatically. For network images, change the Image.asset call inside _ProductCard to Image.network and keep the surrounding Stack as-is so the placeholder colour still shows while loading.

Which Flutter version does it target?

It uses Color.withValues(alpha:), Material 3, and wildcard lambda parameters like (_, _) in the separatorBuilder, so it targets Flutter 3.27+ with Dart 3.7+. On an older SDK, swap withValues(alpha: x) for withOpacity(x) and rename the second wildcard to (_, __).

Related screens