A product list is the building block of every consumer app. This recipe wires the storefront endpoint to a paginated grid with cached_network_image for thumbnail loading. The pattern is taken from reel_moment — a camera rental consumer app — and it generalizes to any catalog.
Endpoints
/storefront/productsNo auth/storefront/product/:idNo auth/storefront/categoriesNo auth/storefront/products accepts pagination via query params: p (page, 1-indexed) and ps (page size). It returns a BaseModel\<T\> list — response.data is the array of product records.
GET /storefront/products?p=1&ps=20&category=cameras
orgid: my-org
200 OK
{
"data": [
{
"pk": "product-...",
"sk": "my-org/product",
"datatype": "product",
"data": {
"name": "Sony A7 IV",
"sku": "sony-a7iv",
"price": 250,
"currency": "USD",
"images": ["https://..."],
"stock": 5
}
}
],
"total": 142,
"page": 1,
"pageSize": 20
}
The storefront endpoints are public — they don't require a JWT. They do still require the orgid header. For protected catalogs (B2B pricing, member-only items), pass a JWT so the server can apply per-customer pricing.
ProductModel
// lib/models/product_model.dart
class ProductModel {
final String pk;
final String name;
final String? sku;
final num price;
final String currency;
final List<String> images;
final int? stock;
ProductModel({
required this.pk,
required this.name,
this.sku,
required this.price,
required this.currency,
required this.images,
this.stock,
});
factory ProductModel.fromJson(Map<String, dynamic> json) {
final data = (json['data'] ?? json) as Map<String, dynamic>;
final imgs = data['images'];
return ProductModel(
pk: json['pk'] as String? ?? '',
name: data['name'] as String? ?? '',
sku: data['sku'] as String?,
price: (data['price'] ?? 0) as num,
currency: data['currency'] as String? ?? 'USD',
images: imgs is List
? imgs.whereType<String>().toList()
: const [],
stock: data['stock'] as int?,
);
}
}
StorefrontService
// lib/services/storefront_service.dart
import '../models/product_model.dart';
import '../services/base_model.dart';
import 'http_client.dart';
class StorefrontService {
final _http = appmintHttp;
Future<ProductPage> products({
int page = 1,
int pageSize = 20,
String? category,
}) async {
final response = await _http.get('/storefront/products',
queryParams: {
'p': page.toString(),
'ps': pageSize.toString(),
if (category != null) 'category': category,
},
requireAuth: false);
final list = BaseModel.unwrapList(response)
.map((j) => ProductModel.fromJson(j as Map<String, dynamic>))
.toList();
return ProductPage(
items: list,
total: BaseModel.totalOf(response),
page: page,
pageSize: pageSize,
);
}
Future<ProductModel> productById(String id) async {
final response = await _http.get('/storefront/product/$id', requireAuth: false);
return ProductModel.fromJson(response as Map<String, dynamic>);
}
}
class ProductPage {
final List<ProductModel> items;
final int total;
final int page;
final int pageSize;
ProductPage({
required this.items,
required this.total,
required this.page,
required this.pageSize,
});
bool get hasMore => page * pageSize < total;
}
Paginated grid
The screen keeps a single list, appends pages on scroll, and uses cached_network_image for thumbnails so images don't re-download on every frame.
// lib/screens/products/products_screen.dart
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../models/product_model.dart';
import '../../services/storefront_service.dart';
class ProductsScreen extends StatefulWidget {
const ProductsScreen({super.key});
@override
State<ProductsScreen> createState() => _ProductsScreenState();
}
class _ProductsScreenState extends State<ProductsScreen> {
final _scroll = ScrollController();
final _service = StorefrontService();
final _items = <ProductModel>[];
int _page = 1;
bool _loading = false;
bool _hasMore = true;
String? _error;
@override
void initState() {
super.initState();
_loadMore();
_scroll.addListener(() {
if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 400 &&
!_loading &&
_hasMore) {
_loadMore();
}
});
}
Future<void> _loadMore() async {
setState(() => _loading = true);
try {
final page = await _service.products(page: _page);
setState(() {
_items.addAll(page.items);
_hasMore = page.hasMore;
_page += 1;
_error = null;
});
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _refresh() async {
setState(() {
_items.clear();
_page = 1;
_hasMore = true;
});
await _loadMore();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: RefreshIndicator(
onRefresh: _refresh,
child: _items.isEmpty && _loading
? const Center(child: CircularProgressIndicator())
: _items.isEmpty && _error != null
? Center(child: Text(_error!))
: GridView.builder(
controller: _scroll,
padding: const EdgeInsets.all(8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.7,
),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == _items.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator()));
}
return ProductCard(product: _items[i]);
},
),
),
);
}
}
class ProductCard extends StatelessWidget {
final ProductModel product;
const ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
final image = product.images.isNotEmpty ? product.images.first : null;
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: 1,
child: image == null
? Container(color: Colors.grey.shade200)
: CachedNetworkImage(
imageUrl: image,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(color: Colors.grey.shade200),
errorWidget: (_, __, ___) =>
const Icon(Icons.broken_image),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'${product.currency} ${product.price}',
style: TextStyle(color: Colors.grey.shade700),
),
],
),
),
],
),
);
}
}
Categories filter
Add a chip row for category filtering. Categories come from /storefront/categories.
final response = await _http.get('/storefront/categories', requireAuth: false);
final categories = BaseModel.unwrapList(response);
When a chip is selected, reset the list and re-fetch with category: chipName.
Search
For full-text search across products, the storefront supports a query parameter (server-dependent — verify against your tenant). For richer search across collections, fall back to the repository search endpoint:
/repository/searchJWTfinal response = await _http.post('/repository/search', body: {
'query': searchText,
'type': 'product',
});
Performance notes
- Image caching:
cached_network_imagehandles disk caching automatically. Default cache is 200 entries / 100 MB — bump it viaDefaultCacheManagerif your catalog is image-heavy. - Don't fetch more pages than the user can see: cap
pageSizeat 20-40. Fetching 200 records up front blocks the first paint. - Don't await image load on the main thread:
CachedNetworkImagedecodes off-thread already; keepplaceholdercheap (a colored box, not another image). - Pull-to-refresh resets pagination: clearing
_itemsand resetting_pageto 1 is the right call. Don't try to merge the new first page with the existing list — you'll get duplicate keys when records shift.
The next recipe — Checkout with Stripe — picks up where this one ends, taking a selected product into a payment flow.