Shopping Cart
An e-commerce shopping cart with product listing, quantity management, persistence across restarts, and optimistic updates. Demonstrates ReactonModule, PersistenceMiddleware, computed derived totals, and store.optimistic for instant UI feedback.
Full Source
import 'package:flutter/material.dart';
import 'package:flutter_reacton/flutter_reacton.dart';
// --- Models ---
class Product {
final String id;
final String name;
final String description;
final double price;
final String imageUrl;
const Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.imageUrl,
});
}
class CartItem {
final Product product;
final int quantity;
const CartItem({required this.product, this.quantity = 1});
double get subtotal => product.price * quantity;
CartItem copyWith({int? quantity}) =>
CartItem(product: product, quantity: quantity ?? this.quantity);
}
// --- Simulated API ---
class ProductApi {
static Future<List<Product>> fetchProducts() async {
await Future.delayed(const Duration(milliseconds: 800));
return const [
Product(
id: 'p1',
name: 'Wireless Headphones',
description: 'Noise-cancelling over-ear headphones with 30h battery.',
price: 149.99,
imageUrl: 'headphones',
),
Product(
id: 'p2',
name: 'Mechanical Keyboard',
description: 'Hot-swappable switches with RGB backlight.',
price: 89.99,
imageUrl: 'keyboard',
),
Product(
id: 'p3',
name: 'USB-C Hub',
description: '7-in-1 hub with HDMI, USB-A, SD card reader.',
price: 49.99,
imageUrl: 'hub',
),
Product(
id: 'p4',
name: 'Laptop Stand',
description: 'Adjustable aluminium stand with cable management.',
price: 39.99,
imageUrl: 'stand',
),
Product(
id: 'p5',
name: 'Webcam HD',
description: '1080p webcam with autofocus and built-in mic.',
price: 69.99,
imageUrl: 'webcam',
),
];
}
/// Simulates a server-side quantity update (may fail).
static Future<void> updateCartOnServer(
String productId, int quantity) async {
await Future.delayed(const Duration(milliseconds: 500));
// Simulate occasional failure
if (productId == 'p3' && quantity > 5) {
throw Exception('Maximum quantity exceeded for this item');
}
}
}
// --- Cart Module ---
class CartModule extends ReactonModule {
/// The cart items, keyed by product ID.
late final itemsReacton = reacton<Map<String, CartItem>>(
{},
name: 'cart.items',
);
/// Computed: all items as a list.
late final itemList = computed<List<CartItem>>(
(read) => read(itemsReacton).values.toList(),
name: 'cart.itemList',
);
/// Computed: total number of items (sum of quantities).
late final itemCount = computed<int>(
(read) => read(itemsReacton)
.values
.fold(0, (sum, item) => sum + item.quantity),
name: 'cart.itemCount',
);
/// Computed: cart subtotal.
late final subtotal = computed<double>(
(read) => read(itemsReacton)
.values
.fold(0.0, (sum, item) => sum + item.subtotal),
name: 'cart.subtotal',
);
/// Computed: shipping estimate (free over $100).
late final shipping = computed<double>(
(read) {
final total = read(subtotal);
if (total == 0) return 0.0;
return total >= 100.0 ? 0.0 : 9.99;
},
name: 'cart.shipping',
);
/// Computed: grand total including shipping.
late final grandTotal = computed<double>(
(read) => read(subtotal) + read(shipping),
name: 'cart.grandTotal',
);
/// Add a product to the cart or increment its quantity.
void addItem(ReactonStore store, Product product) {
store.update(itemsReacton, (items) {
final existing = items[product.id];
return {
...items,
product.id: existing != null
? existing.copyWith(quantity: existing.quantity + 1)
: CartItem(product: product),
};
});
}
/// Remove a product from the cart entirely.
void removeItem(ReactonStore store, String productId) {
store.update(itemsReacton, (items) {
final updated = Map<String, CartItem>.from(items);
updated.remove(productId);
return updated;
});
}
/// Update quantity with optimistic update.
Future<void> updateQuantity(
ReactonStore store,
String productId,
int newQuantity,
) async {
if (newQuantity <= 0) {
removeItem(store, productId);
return;
}
// Optimistic: update the UI immediately, roll back on failure
await store.optimistic(
reacton: itemsReacton,
optimisticValue: () {
final items = store.get(itemsReacton);
final item = items[productId];
if (item == null) return items;
return {
...items,
productId: item.copyWith(quantity: newQuantity),
};
}(),
mutation: () =>
ProductApi.updateCartOnServer(productId, newQuantity),
);
}
@override
void register(ReactonStore store) {
store.register(itemsReacton);
store.register(itemCount);
store.register(subtotal);
store.register(shipping);
store.register(grandTotal);
store.register(itemList);
}
}
// --- Reactons ---
/// Products query with caching.
final productsQuery = reactonQuery<List<Product>>(
queryFn: (_) => ProductApi.fetchProducts(),
config: QueryConfig(
staleTime: const Duration(minutes: 10),
cacheTime: const Duration(minutes: 30),
),
name: 'productsQuery',
);
/// Cart module instance.
final cart = CartModule();
// --- Persistence ---
/// Serializer for the cart items map.
class CartSerializer extends Serializer<Map<String, CartItem>> {
@override
String serialize(Map<String, CartItem> value) {
// In production, use json_serializable or similar
return value.entries
.map((e) => '${e.key}:${e.value.quantity}')
.join(',');
}
@override
Map<String, CartItem> deserialize(String raw) {
if (raw.isEmpty) return {};
// Simplified: real app would reconstruct full CartItem from product catalog
return {};
}
}
// --- App ---
void main() {
final store = ReactonStore();
// Register the cart module
cart.register(store);
// Enable persistence for cart items
store.addMiddleware(PersistenceMiddleware(
adapter: SharedPreferencesAdapter(),
entries: [
PersistenceEntry(
reacton: cart.itemsReacton,
key: 'cart_items',
serializer: CartSerializer(),
),
],
));
runApp(ReactonScope(store: store, child: const ShopApp()));
}
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Shopping Cart',
theme: ThemeData(colorSchemeSeed: Colors.orange, useMaterial3: true),
home: const ProductListPage(),
);
}
}
// --- Product List ---
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
@override
Widget build(BuildContext context) {
final queryState = context.watch(productsQuery);
final cartCount = context.watch(cart.itemCount);
return Scaffold(
appBar: AppBar(
title: const Text('Shop'),
actions: [
Badge(
label: Text('$cartCount'),
isLabelVisible: cartCount > 0,
child: IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const CartPage()),
),
),
),
],
),
body: queryState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
data: (products) => ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return _ProductCard(product: product);
},
),
),
);
}
}
class _ProductCard extends StatelessWidget {
final Product product;
const _ProductCard({required this.product});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.inventory_2, size: 48, color: Colors.orange.shade300),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(product.description,
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 8),
Text('\$${product.price.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold)),
],
),
),
FilledButton.tonal(
onPressed: () => cart.addItem(context.reactonStore, product),
child: const Text('Add'),
),
],
),
),
);
}
}
// --- Cart Page ---
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override
Widget build(BuildContext context) {
return ReactonConsumer(
builder: (context, ref) {
final items = ref.watch(cart.itemList);
final subtotalVal = ref.watch(cart.subtotal);
final shippingVal = ref.watch(cart.shipping);
final totalVal = ref.watch(cart.grandTotal);
return Scaffold(
appBar: AppBar(title: const Text('Cart')),
body: items.isEmpty
? const Center(child: Text('Your cart is empty'))
: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return _CartItemTile(item: item);
},
),
),
// Order summary
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
_SummaryRow(
label: 'Subtotal',
value: '\$${subtotalVal.toStringAsFixed(2)}',
),
_SummaryRow(
label: 'Shipping',
value: shippingVal == 0
? 'FREE'
: '\$${shippingVal.toStringAsFixed(2)}',
),
const Divider(),
_SummaryRow(
label: 'Total',
value: '\$${totalVal.toStringAsFixed(2)}',
bold: true,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {},
child: const Text('Checkout'),
),
),
],
),
),
],
),
);
},
);
}
}
class _CartItemTile extends StatelessWidget {
final CartItem item;
const _CartItemTile({required this.item});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.product.name,
style: Theme.of(context).textTheme.titleSmall),
Text('\$${item.product.price.toStringAsFixed(2)}'),
],
),
),
// Quantity controls
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => cart.updateQuantity(
context.reactonStore,
item.product.id,
item.quantity - 1,
),
),
Text('${item.quantity}',
style: Theme.of(context).textTheme.titleMedium),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => cart.updateQuantity(
context.reactonStore,
item.product.id,
item.quantity + 1,
),
),
// Subtotal
SizedBox(
width: 70,
child: Text(
'\$${item.subtotal.toStringAsFixed(2)}',
textAlign: TextAlign.right,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Delete
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
cart.removeItem(context.reactonStore, item.product.id),
),
],
),
),
);
}
}
class _SummaryRow extends StatelessWidget {
final String label;
final String value;
final bool bold;
const _SummaryRow({
required this.label,
required this.value,
this.bold = false,
});
@override
Widget build(BuildContext context) {
final style = bold
? Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold)
: Theme.of(context).textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(label, style: style), Text(value, style: style)],
),
);
}
}Walkthrough
Data Models
Product represents an item in the catalog. CartItem pairs a product with a quantity and exposes a computed subtotal getter. The copyWith method on CartItem enables immutable quantity updates.
Cart Module
The CartModule class extends ReactonModule to group all cart-related state and logic:
class CartModule extends ReactonModule {
late final itemsReacton = reacton<Map<String, CartItem>>({}, name: 'cart.items');
late final itemCount = computed<int>(
(read) => read(itemsReacton).values.fold(0, (sum, item) => sum + item.quantity),
name: 'cart.itemCount',
);
// ... subtotal, shipping, grandTotal
}Modules provide encapsulation: the cart's reactons, computed values, and mutation methods all live in one class. The register method ensures all reactons are registered with the store.
Computed Chains
The computed reactons form a dependency chain:
itemsReacton(source) holds the cart mapsubtotalreadsitemsReactonand sums each item's subtotalshippingreadssubtotaland returns 0 if the total is over $100grandTotalreads bothsubtotalandshipping
When an item quantity changes, the entire chain recomputes efficiently -- only the reactons whose inputs actually changed will recompute.
Optimistic Updates
Future<void> updateQuantity(ReactonStore store, String productId, int newQuantity) async {
await store.optimistic(
reacton: itemsReacton,
optimisticValue: () {
final items = store.get(itemsReacton);
final item = items[productId];
if (item == null) return items;
return { ...items, productId: item.copyWith(quantity: newQuantity) };
}(),
mutation: () => ProductApi.updateCartOnServer(productId, newQuantity),
);
}store.optimistic sets the value immediately so the UI updates without waiting for the server. If the mutation future fails, the store automatically rolls back to the previous value. This gives the user instant feedback while maintaining data consistency.
Persistence
store.addMiddleware(PersistenceMiddleware(
adapter: SharedPreferencesAdapter(),
entries: [
PersistenceEntry(
reacton: cart.itemsReacton,
key: 'cart_items',
serializer: CartSerializer(),
),
],
));PersistenceMiddleware saves the cart to SharedPreferences whenever itemsReacton changes. On app restart, the middleware restores the persisted value before the first frame renders. The CartSerializer handles conversion to and from a storable format.
Badge on Cart Icon
Badge(
label: Text('$cartCount'),
isLabelVisible: cartCount > 0,
child: IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () => Navigator.push(...),
),
),cartCount is a computed reacton that sums all quantities. The Badge widget displays the count and hides itself when the cart is empty.
Key Takeaways
- ReactonModule groups related state -- Cart items, counts, totals, and mutation methods live in one class, making the feature self-contained and testable.
- Computed chains propagate efficiently --
subtotal -> shipping -> grandTotalrecompute only when upstream values change. - Optimistic updates provide instant feedback --
store.optimisticupdates the UI immediately and rolls back automatically on failure. - PersistenceMiddleware survives restarts -- Configure once, and the cart data is saved and restored transparently.
- Modules register all their reactons -- The
registermethod ensures the store knows about every reacton in the module.
What's Next
- Search with Debounce -- Debounced API calls with query caching
- Multi-Step Wizard -- State machines and form reactons for checkout flows
- Offline-First -- Full offline support with sync