Documentation

Browse products

Storefront product list with pagination and image loading via cached_network_image.

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

GET/storefront/productsNo auth
GET/storefront/product/:idNo auth
GET/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:

POST/repository/searchJWT
final response = await _http.post('/repository/search', body: {
  'query': searchText,
  'type': 'product',
});

Performance notes

  • Image caching: cached_network_image handles disk caching automatically. Default cache is 200 entries / 100 MB — bump it via DefaultCacheManager if your catalog is image-heavy.
  • Don't fetch more pages than the user can see: cap pageSize at 20-40. Fetching 200 records up front blocks the first paint.
  • Don't await image load on the main thread: CachedNetworkImage decodes off-thread already; keep placeholder cheap (a colored box, not another image).
  • Pull-to-refresh resets pagination: clearing _items and resetting _page to 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.