How to Build an Empty Wishlist Screen in Flutter (Full Code + Preview)
A wishlist tab with nothing in it is the moment a shopper decides whether saving items is worth the effort. This tutorial builds StyleCart's empty wishlist screen in Flutter: a 150px painted heart illustration with a pink spark, a 'No saves yet' headline, a line that teaches the heart-tap gesture in one sentence, a full-width Browse products button, and a horizontal Trending now rail whose cards each carry a heart badge — four real products to make the first save one tap away.

What you'll build
- ✓A CustomPainter heart — white fill, 2.6px dark outline, pink spark glint — that reads as unfilled rather than broken
- ✓Subline copy that names the heart control instead of apologising for the empty tab
- ✓A 54px 'Browse products' FilledButton in StyleCart's #FF385C brand pink
- ✓A 226px horizontal 'Trending now' rail of four tappable product cards, each wearing an outlined heart badge
- ✓A scrollable zero-state that survives a small phone without a single overflow stripe
Step-by-step build
Create the file
Add a new file at lib/ecom_wishlist_empty/ecom_wishlist_empty_screen.dart in your Flutter project.
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:
flutter:
fonts:
- family: Manrope
fonts:
- asset: fonts/Manrope-Regular.ttfBuild 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.
Callbacks, tokens and the trending products as data
import 'package:flutter/material.dart';
/// StyleCart — Empty Wishlist.
///
/// The zero-state for the Wishlist tab: a painted broken-heart illustration, a
/// nudge to start saving, a "Browse products" CTA and a trending rail to seed
/// the first save.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. The
/// illustration is a CustomPainter (no emoji glyph). Exposes callbacks only.
class EcomWishlistEmptyScreen extends StatelessWidget {
const EcomWishlistEmptyScreen({
super.key,
this.onBack,
this.onBrowse,
this.onProduct,
});
final VoidCallback? onBack;
final VoidCallback? onBrowse;
final ValueChanged<String>? onProduct;
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 _imageBg = Color(0xFFF5F5F5);
static const Color _hairline = Color(0xFFEBEBEB);
static const String _dir = 'lib/screens/ecommerce/ecom_wishlist_empty/images';
static const List<_P> _trending = <_P>[
_P('Oversized blazer', 'Atelier', 176, 'p07.webp'),
_P('Ribbed knit tank', 'Aria', 48, 'p08.webp'),
_P('Tapered chino', 'Stride', 88, 'p09.webp'),
_P('Suede loafers', 'Stride', 124, 'p10.webp'),
];The screen is a `StatelessWidget` with three optional callbacks — `onBack`, `onBrowse`, `onProduct` — because a wishlist holding zero items has no state of its own to mutate; every tap leaves for somewhere else. `_brand` (#FF385C) is spent in exactly two places, the Browse button and the painter's spark, so the pink always means 'act here'. `_imageBg` (#F5F5F5) does double duty as the rail's thumbnail backing and the illustration's disc. The four trending items live in a `static const List<_P>`, each a title, brand, price and webp filename, with `_dir` holding the shared asset folder so a card only names its own file.
A scrolling zero-state, not a centred one
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.light(useMaterial3: true),
child: Scaffold(
backgroundColor: _canvas,
body: SafeArea(
child: Column(
children: <Widget>[
_header(),
const Divider(height: 1, color: _hairline),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 28),
children: <Widget>[
const SizedBox(height: 40),
Center(
child: SizedBox(
width: 150,
height: 150,
child: CustomPaint(painter: _HeartArtPainter()),
),
),`Theme(data: ThemeData.light(useMaterial3: true))` pins the light look regardless of the host app, then a white `Scaffold` and `SafeArea` wrap a Column of header, a 1px `Divider` in `_hairline`, and an `Expanded` `ListView`. Scrolling matters here: the illustration, headline, subline, CTA and a 226px rail together exceed a small phone, and a `Column` with `Spacer`s would overflow the moment the rail appeared. The list's `EdgeInsets.only(bottom: 28)` keeps the last row clear of the home indicator. A 40px gap, then a `Center` holding a fixed 150×150 `SizedBox` — the painter works in fractions of `size`, so the box is what sets the artwork's scale.
Copy that names the heart, and the browse CTA
const SizedBox(height: 28),
const Text(
'No saves yet',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: _font,
fontSize: 21,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: _ink,
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 48),
child: Text(
'Tap the heart on anything you love and it’ll '
'land here — ready when you are.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: _font,
fontSize: 14.5,
fontWeight: FontWeight.w500,
height: 1.5,
color: _muted,
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: SizedBox(
height: 54,
child: FilledButton(
onPressed: onBrowse,
style: FilledButton.styleFrom(
backgroundColor: _brand,
foregroundColor: _canvas,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text(
'Browse products',
style: TextStyle(
fontFamily: _font,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
),
),'No saves yet' lands at 21px `w800` with `letterSpacing: -0.3`, and 8px under it the subline reads 'Tap the heart on anything you love and it'll land here — ready when you are.' That sentence is the whole job of the screen: a wishlist is empty because the shopper hasn't noticed the heart, so the copy points at the control rather than apologising. `EdgeInsets.symmetric(horizontal: 48)` forces a comfortable two-line wrap at 14.5px with `height: 1.5` in `_muted`. After 24px, the `FilledButton` sits in 40px side padding at a fixed `height: 54` with a 16px radius and brand fill, firing `onBrowse`.
Handing off to the trending rail
const SizedBox(height: 44),
const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 14),
child: Text(
'Trending now',
style: TextStyle(
fontFamily: _font,
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: _ink,
),
),
),
_rail(),
],
),
),
],
),
),
),
);
}A deliberate 44px gap — the largest on the screen — separates the zero-state block from what follows, so the rail reads as a second offer rather than part of the illustration. 'Trending now' is set at 17px `w700` with `EdgeInsets.fromLTRB(20, 0, 20, 14)`, its 20px left inset matching the rail's own padding so title and first card share an edge. Both the heading and `_rail()` are children of the same `ListView`, which means the section scrolls away with the artwork instead of pinning; the rail's own horizontal scrolling is nested inside that vertical list without conflict, since the axes differ.
A header that keeps the tab's identity
Widget _header() {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 20, 4),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new_rounded,
size: 20, color: _ink),
),
const Expanded(
child: Text(
'Wishlist',
style: TextStyle(
fontFamily: _font,
fontSize: 19,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: _ink,
),
),
),
],
),
);
}`EdgeInsets.fromLTRB(8, 4, 20, 4)` uses only 8px on the left because the `IconButton` already carries a 48px touch target that would otherwise push the arrow too far inboard. The glyph is `Icons.arrow_back_ios_new_rounded` at 20px in `_ink`. The 'Wishlist' title sits inside an `Expanded` rather than being centred, so it reads as a left-aligned screen name beside the arrow and the row's width is claimed in one widget. There is no trailing action — a wishlist with zero saves has nothing to edit, share or clear, so an overflow menu would open onto disabled items.
The Trending now rail
Widget _rail() {
return SizedBox(
height: 226,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: _trending.length,
separatorBuilder: (_, _) => const SizedBox(width: 14),
itemBuilder: (BuildContext context, int i) {
final _P p = _trending[i];
return GestureDetector(
onTap: () => onProduct?.call(p.title),
child: SizedBox(
width: 140,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
Container(color: _imageBg),
Image.asset('$_dir/${p.asset}', fit: BoxFit.cover),
const Positioned(top: 8, right: 8, child: _Heart()),
],
),
),
),
const SizedBox(height: 8),
Text(
p.brand.toUpperCase(),
style: const TextStyle(
fontFamily: _font,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: _muted,
),
),
const SizedBox(height: 2),
Text(
p.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 3),
Text(
'\$${p.price}',
style: const TextStyle(
fontFamily: _font,
fontSize: 14,
fontWeight: FontWeight.w700,
color: _ink,
),
),
],
),
),
);
},
),
);
}A fixed `SizedBox(height: 226)` gives the horizontal `ListView.separated` a bound, which lets each card's `Column` hand the image an `Expanded` while the four text lines take their intrinsic heights. Separators are 14px; each card is 140 wide. The whole card is a `GestureDetector` calling `onProduct?.call(p.title)`. Inside a `ClipRRect` with a 14px radius, a `Stack` layers `Container(color: _imageBg)` beneath `Image.asset` so a decoding frame shows grey rather than white flash, with the `_Heart` badge pinned at `top: 8, right: 8`. Brand is uppercased at 10px with `letterSpacing: 0.6`, the title clipped to one line with `TextOverflow.ellipsis`, and the price bolded at `w700`.
The heart badge and the product record
class _Heart extends StatelessWidget {
const _Heart();
@override
Widget build(BuildContext context) {
return Container(
width: 30,
height: 30,
decoration: const BoxDecoration(
color: Color(0xFFFFFFFF),
shape: BoxShape.circle,
boxShadow: <BoxShadow>[
BoxShadow(
color: Color(0x14000000),
blurRadius: 5,
offset: Offset(0, 2),
),
],
),
child: const Icon(Icons.favorite_border_rounded,
size: 16, color: Color(0xFF222222)),
);
}
}
class _P {
const _P(this.title, this.brand, this.price, this.asset);
final String title;
final String brand;
final int price;
final String asset;
}`_Heart` is a 30px white circle carrying `Icons.favorite_border_rounded` at 16px, lifted off the photo by a single `BoxShadow` at `Color(0x14000000)`, `blurRadius: 5`, `offset: Offset(0, 2)` — enough separation to stay legible over a busy garment shot without a hard ring. The glyph is the *outline* variant on every card, which is the point: nothing here is saved yet, so the badge shows the shopper the exact control the subline just described. Note it has no `onTap` of its own — the enclosing `GestureDetector` sends taps to `onProduct` instead. `_P` is a four-field const record holding title, brand, price and asset name.
Painting the heart and its spark
/// Paints a soft outlined heart with a brand spark, the wishlist zero-state
/// motif — empty but inviting.
class _HeartArtPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final double w = size.width;
final double h = size.height;
// Rounded backdrop.
canvas.drawCircle(
Offset(w / 2, h / 2),
w / 2,
Paint()..color = const Color(0xFFF5F5F5),
);
// Heart outline, centred.
final Path heart = _heartPath(
Offset(w / 2, h * 0.52),
w * 0.42,
h * 0.40,
);
canvas.drawPath(heart, Paint()..color = const Color(0xFFFFFFFF));
canvas.drawPath(
heart,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.6
..strokeJoin = StrokeJoin.round
..color = const Color(0xFF222222),
);
// Brand spark — a tiny plus glint to the upper-right.
final Paint spark = Paint()
..color = const Color(0xFFFF385C)
..strokeWidth = 2.4
..strokeCap = StrokeCap.round;
final Offset s = Offset(w * 0.74, h * 0.30);
canvas.drawLine(s.translate(-5, 0), s.translate(5, 0), spark);
canvas.drawLine(s.translate(0, -5), s.translate(0, 5), spark);
}
Path _heartPath(Offset c, double width, double height) {
final double l = c.dx - width / 2;
final double t = c.dy - height / 2;
final Path p = Path();
p.moveTo(c.dx, t + height);
p.cubicTo(
l - width * 0.05, t + height * 0.62,
l + width * 0.10, t - height * 0.10,
c.dx, t + height * 0.30,
);
p.cubicTo(
l + width * 0.90, t - height * 0.10,
l + width * 1.05, t + height * 0.62,
c.dx, t + height,
);
p.close();
return p;
}
@override
bool shouldRepaint(_HeartArtPainter oldDelegate) => false;
}Despite the 'broken heart' label in the docs, `_HeartArtPainter` draws a whole one — unfilled, not broken, a friendlier read of zero saves. A #F5F5F5 disc at radius `w / 2` fills the box, then the heart sits at `(w / 2, h * 0.52)` — below true centre so the lobes clear the disc edge — sized `w * 0.42` by `h * 0.40`, filled white then stroked at 2.6px in #222. The pink spark is two crossed 10px `drawLine` calls at `(w * 0.74, h * 0.30)`. `_heartPath` starts at the bottom point and runs two mirrored cubics whose control points overshoot the box to bulge each lobe, meeting at the cleft at `t + height * 0.30`.
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 — Empty Wishlist.
///
/// The zero-state for the Wishlist tab: a painted broken-heart illustration, a
/// nudge to start saving, a "Browse products" CTA and a trending rail to seed
/// the first save.
///
/// Self-contained per CONVENTIONS.md: pure Flutter, bundled Manrope, inline
/// Airbnb-style tokens, own light theme + SafeArea, bundled webp. The
/// illustration is a CustomPainter (no emoji glyph). Exposes callbacks only.
class EcomWishlistEmptyScreen extends StatelessWidget {
const EcomWishlistEmptyScreen({
super.key,
this.onBack,
this.onBrowse,
this.onProduct,
});
final VoidCallback? onBack;
final VoidCallback? onBrowse;
final ValueChanged<String>? onProduct;
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 _imageBg = Color(0xFFF5F5F5);
static const Color _hairline = Color(0xFFEBEBEB);
static const String _dir = 'lib/screens/ecommerce/ecom_wishlist_empty/images';
static const List<_P> _trending = <_P>[
_P('Oversized blazer', 'Atelier', 176, 'p07.webp'),
_P('Ribbed knit tank', 'Aria', 48, 'p08.webp'),
_P('Tapered chino', 'Stride', 88, 'p09.webp'),
_P('Suede loafers', 'Stride', 124, 'p10.webp'),
];
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData.light(useMaterial3: true),
child: Scaffold(
backgroundColor: _canvas,
body: SafeArea(
child: Column(
children: <Widget>[
_header(),
const Divider(height: 1, color: _hairline),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 28),
children: <Widget>[
const SizedBox(height: 40),
Center(
child: SizedBox(
width: 150,
height: 150,
child: CustomPaint(painter: _HeartArtPainter()),
),
),
const SizedBox(height: 28),
const Text(
'No saves yet',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: _font,
fontSize: 21,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: _ink,
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 48),
child: Text(
'Tap the heart on anything you love and it’ll '
'land here — ready when you are.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: _font,
fontSize: 14.5,
fontWeight: FontWeight.w500,
height: 1.5,
color: _muted,
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: SizedBox(
height: 54,
child: FilledButton(
onPressed: onBrowse,
style: FilledButton.styleFrom(
backgroundColor: _brand,
foregroundColor: _canvas,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text(
'Browse products',
style: TextStyle(
fontFamily: _font,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
),
),
const SizedBox(height: 44),
const Padding(
padding: EdgeInsets.fromLTRB(20, 0, 20, 14),
child: Text(
'Trending now',
style: TextStyle(
fontFamily: _font,
fontSize: 17,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: _ink,
),
),
),
_rail(),
],
),
),
],
),
),
),
);
}
Widget _header() {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 20, 4),
child: Row(
children: <Widget>[
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back_ios_new_rounded,
size: 20, color: _ink),
),
const Expanded(
child: Text(
'Wishlist',
style: TextStyle(
fontFamily: _font,
fontSize: 19,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: _ink,
),
),
),
],
),
);
}
Widget _rail() {
return SizedBox(
height: 226,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: _trending.length,
separatorBuilder: (_, _) => const SizedBox(width: 14),
itemBuilder: (BuildContext context, int i) {
final _P p = _trending[i];
return GestureDetector(
onTap: () => onProduct?.call(p.title),
child: SizedBox(
width: 140,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
Container(color: _imageBg),
Image.asset('$_dir/${p.asset}', fit: BoxFit.cover),
const Positioned(top: 8, right: 8, child: _Heart()),
],
),
),
),
const SizedBox(height: 8),
Text(
p.brand.toUpperCase(),
style: const TextStyle(
fontFamily: _font,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: _muted,
),
),
const SizedBox(height: 2),
Text(
p.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: _font,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 3),
Text(
'\$${p.price}',
style: const TextStyle(
fontFamily: _font,
fontSize: 14,
fontWeight: FontWeight.w700,
color: _ink,
),
),
],
),
),
);
},
),
);
}
}
class _Heart extends StatelessWidget {
const _Heart();
@override
Widget build(BuildContext context) {
return Container(
width: 30,
height: 30,
decoration: const BoxDecoration(
color: Color(0xFFFFFFFF),
shape: BoxShape.circle,
boxShadow: <BoxShadow>[
BoxShadow(
color: Color(0x14000000),
blurRadius: 5,
offset: Offset(0, 2),
),
],
),
child: const Icon(Icons.favorite_border_rounded,
size: 16, color: Color(0xFF222222)),
);
}
}
class _P {
const _P(this.title, this.brand, this.price, this.asset);
final String title;
final String brand;
final int price;
final String asset;
}
/// Paints a soft outlined heart with a brand spark, the wishlist zero-state
/// motif — empty but inviting.
class _HeartArtPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final double w = size.width;
final double h = size.height;
// Rounded backdrop.
canvas.drawCircle(
Offset(w / 2, h / 2),
w / 2,
Paint()..color = const Color(0xFFF5F5F5),
);
// Heart outline, centred.
final Path heart = _heartPath(
Offset(w / 2, h * 0.52),
w * 0.42,
h * 0.40,
);
canvas.drawPath(heart, Paint()..color = const Color(0xFFFFFFFF));
canvas.drawPath(
heart,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.6
..strokeJoin = StrokeJoin.round
..color = const Color(0xFF222222),
);
// Brand spark — a tiny plus glint to the upper-right.
final Paint spark = Paint()
..color = const Color(0xFFFF385C)
..strokeWidth = 2.4
..strokeCap = StrokeCap.round;
final Offset s = Offset(w * 0.74, h * 0.30);
canvas.drawLine(s.translate(-5, 0), s.translate(5, 0), spark);
canvas.drawLine(s.translate(0, -5), s.translate(0, 5), spark);
}
Path _heartPath(Offset c, double width, double height) {
final double l = c.dx - width / 2;
final double t = c.dy - height / 2;
final Path p = Path();
p.moveTo(c.dx, t + height);
p.cubicTo(
l - width * 0.05, t + height * 0.62,
l + width * 0.10, t - height * 0.10,
c.dx, t + height * 0.30,
);
p.cubicTo(
l + width * 0.90, t - height * 0.10,
l + width * 1.05, t + height * 0.62,
c.dx, t + height,
);
p.close();
return p;
}
@override
bool shouldRepaint(_HeartArtPainter oldDelegate) => false;
}
Plus bundled 9 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-wishlist-empty2. AI agent (MCP)
Connect FlutterKit's MCP server in Claude or Cursor and just ask your agent to install ecom-wishlist-empty — it fetches and writes the files for you.
FAQ
Is this empty wishlist screen free to use commercially?
Yes — free, with nothing paid behind it. Copy the Dart from this page, install it with the CLI, or pull it through MCP, and ship it in a real shopping app. No sign-up, no licence key, no attribution line.
Which packages, fonts and images does it need?
No pub packages at all — `material.dart` only. Text uses bundled Manrope via `fontFamily: 'Manrope'`, and the four rail cards load webp files from `lib/screens/ecommerce/ecom_wishlist_empty/images`. Point `_dir` at your own asset folder, or replace `Image.asset` with `Image.network` for a live catalogue; the grey `_imageBg` layer beneath already covers the loading frame.
How do I make the heart badge actually save a product?
`_Heart` is decorative today — it has no `onTap`, and the card's `GestureDetector` routes every tap to `onProduct`. Convert the screen to a `StatefulWidget`, hold a `Set<String>` of saved titles, wrap `_Heart` in its own `GestureDetector`, and pass a `saved` flag so it swaps `Icons.favorite_border_rounded` for `Icons.favorite_rounded` in `_brand`.
Why put a trending rail on a screen about having nothing saved?
Because the only fix for an empty wishlist is a first save, and the Browse button costs a navigation before the shopper sees a single product. Four real cards with prices and heart badges put a candidate item on this screen, so the tab can go from zero to one without leaving it.
Which Flutter version does this need?
Flutter 3.29 or newer, because `separatorBuilder: (_, _) =>` uses Dart 3.7 wildcard parameters. On an older SDK rename them to `(_, __)`, and if you are below 3.22 also expand the constructor from `super.key` to the `{Key? key, ...}) : super(key: key)` form.