电商App是Flutter最复杂的实战项目之一。它涉及商品展示、分类浏览、搜索过滤、购物车管理、结算支付、订单跟踪等完整购物流程。本课将用Provider管理全局状态,构建一个多页面电商App,重点攻克购物车状态同步和复杂列表渲染。
// lib/models/product.dart
class Product {
final String id;
final String name;
final String description;
final double price;
final double? originalPrice; // 原价(折扣用)
final List<String> images;
final String category;
final List<String> tags;
final double rating;
int reviewCount;
int stock;
final Map<String, List<String>> variants; // 如 {"颜色": ["红","蓝"], "尺码": ["S","M","L"]}
const Product({
required this.id, required this.name, required this.description,
required this.price, this.originalPrice, required this.images,
required this.category, this.tags = const [],
this.rating = 0, this.reviewCount = 0, this.stock = 0,
this.variants = const {},
});
bool get hasDiscount => originalPrice != null && originalPrice! > price;
double get discountPercent => hasDiscount
? ((1 - price / originalPrice!) * 100).roundToDouble() : 0;
bool get isLowStock => stock > 0 && stock <= 5;
bool get isOutOfStock => stock <= 0;
}
// 购物车项
class CartItem {
final Product product;
final int quantity;
final Map<String, String> selectedVariants; // {"颜色": "红", "尺码": "M"}
const CartItem({
required this.product, this.quantity = 1,
this.selectedVariants = const {},
});
double get totalPrice => product.price * quantity;
String get variantKey => selectedVariants.entries
.map((e) => '${e.key}:${e.value}').join('|');
CartItem copyWith({int? quantity, Map<String, String>? selectedVariants}) =>
CartItem(
product: product,
quantity: quantity ?? this.quantity,
selectedVariants: selectedVariants ?? this.selectedVariants,
);
}
// 订单
class Order {
final String id;
final List<CartItem> items;
final OrderStatus status;
final Address shippingAddress;
final double shippingFee;
final double discount;
final DateTime createdAt;
const Order({
required this.id, required this.items, required this.status,
required this.shippingAddress, this.shippingFee = 0,
this.discount = 0, required this.createdAt,
});
double get subtotal => items.fold(0, (sum, i) => sum + i.totalPrice);
double get total => subtotal + shippingFee - discount;
}
enum OrderStatus { pending, paid, shipped, delivered, cancelled }
// lib/providers/cart_provider.dart
class CartProvider extends ChangeNotifier {
final Map<String, CartItem> _items = {}; // key: "${productId}_$variantKey"
Map<String, CartItem> get items => Map.unmodifiable(_items);
int get itemCount => _items.values.fold(0, (sum, i) => sum + i.quantity);
bool get isEmpty => _items.isEmpty;
double get subtotal => _items.values.fold(0, (sum, i) => sum + i.totalPrice);
// 计算运费(满99免运费)
double get shippingFee => subtotal >= 99 ? 0 : 10;
// 计算折扣
double get discount {
double d = 0;
if (subtotal >= 200) d = 30; // 满200减30
else if (subtotal >= 100) d = 10; // 满100减10
return d;
}
double get total => subtotal + shippingFee - discount;
// 添加商品到购物车
void addItem(Product product, {Map<String, String> variants = const {}}) {
final key = '${product.id}_${variants.entries.map((e) => '${e.key}:${e.value}').join('|')}';
if (_items.containsKey(key)) {
_items[key] = _items[key]!.copyWith(
quantity: _items[key]!.quantity + 1,
);
} else {
_items[key] = CartItem(
product: product,
selectedVariants: variants,
);
}
notifyListeners();
}
// 更新数量
void updateQuantity(String key, int quantity) {
if (quantity <= 0) {
_items.remove(key);
} else {
_items[key] = _items[key]!.copyWith(quantity: quantity);
}
notifyListeners();
}
// 移除商品
void removeItem(String key) {
_items.remove(key);
notifyListeners();
}
// 清空购物车
void clear() {
_items.clear();
notifyListeners();
}
}
// lib/pages/product_list_page.dart
class ProductListPage extends StatefulWidget {
final String? category;
const ProductListPage({this.category});
@override
State<ProductListPage> createState() => _ProductListPageState();
}
class _ProductListPageState extends State<ProductListPage> {
final _scrollController = ScrollController();
List<Product> _products = [];
bool _isLoading = false;
bool _hasMore = true;
int _page = 1;
String _sort = 'default'; // default, price_asc, price_desc, rating
@override
void initState() {
super.initState();
_loadProducts();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadProducts();
}
}
Future<void> _loadProducts() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
final newProducts = await ProductService().getProducts(
category: widget.category,
page: _page,
sort: _sort,
);
setState(() {
_products.addAll(newProducts);
_hasMore = newProducts.length >= 20;
_page++;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.category ?? '全部商品'),
actions: [
IconButton(icon: const Icon(Icons.search),
onPressed: () => showSearch(
context: context,
delegate: ProductSearchDelegate(),
)),
_buildSortButton(),
],
),
body: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.65,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _products.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _products.length) {
return const Center(child: CircularProgressIndicator());
}
return ProductCard(
product: _products[index],
onTap: () => _navigateToDetail(_products[index]),
onAddToCart: () => _addToCart(_products[index]),
);
},
),
);
}
}
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback onTap;
final VoidCallback onAddToCart;
const ProductCard({
required this.product, required this.onTap, required this.onAddToCart,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 商品图片
Expanded(
flex: 3,
child: Stack(
children: [
Image.network(product.images.first,
width: double.infinity, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: Colors.grey[200],
child: const Icon(Icons.image_not_supported))),
// 折扣标签
if (product.hasDiscount)
Positioned(top: 8, left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.red, borderRadius: BorderRadius.circular(4)),
child: Text('-${product.discountPercent.toInt()}%',
style: const TextStyle(color: Colors.white, fontSize: 12)),
)),
// 库存预警
if (product.isLowStock)
Positioned(bottom: 8, right: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange, borderRadius: BorderRadius.circular(4)),
child: const Text('仅剩${product.stock}件',
style: TextStyle(color: Colors.white, fontSize: 10)),
)),
],
),
),
// 商品信息
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Row(
children: [
Text('¥${product.price.toStringAsFixed(2)}',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary)),
if (product.hasDiscount) ...[
const SizedBox(width: 4),
Text('¥${product.originalPrice!.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 11, decoration: TextDecoration.lineThrough,
color: Colors.grey)),
],
],
),
const Spacer(),
Align(
alignment: Alignment.centerRight,
child: IconButton.filled(
icon: const Icon(Icons.add_shopping_cart, size: 18),
onPressed: onAddToCart,
style: IconButton.styleFrom(
minimumSize: const Size(32, 32),
padding: EdgeInsets.zero,
),
),
),
],
),
),
),
],
),
),
);
}
}
// lib/pages/cart_page.dart
class CartPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('购物车')),
body: Consumer<CartProvider>(
builder: (context, cart, _) {
if (cart.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shopping_cart_outlined, size: 80, color: Colors.grey),
SizedBox(height: 16),
Text('购物车空空如也', style: TextStyle(fontSize: 18, color: Colors.grey)),
],
),
);
}
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (context, index) {
final entry = cart.items.entries.elementAt(index);
return CartItemTile(
item: entry.value,
onQuantityChanged: (q) =>
cart.updateQuantity(entry.key, q),
onRemove: () => cart.removeItem(entry.key),
);
},
),
),
_buildCheckoutBar(context, cart),
],
);
},
),
);
}
Widget _buildCheckoutBar(BuildContext context, CartProvider cart) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 8, offset: const Offset(0, -2))],
),
child: SafeArea(
child: Row(
children: [
Text('合计:', style: const TextStyle(fontSize: 14)),
Text('¥${cart.total.toStringAsFixed(2)}',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary)),
const Spacer(),
SizedBox(
width: 120, height: 44,
child: FilledButton(
onPressed: () => _checkout(context, cart),
child: Text('结算(${cart.itemCount})'),
),
),
],
),
),
);
}
}
你已构建了一个电商App!掌握了:
下一课预告:发布与打包——签名配置、渠道包、CI/CD自动化、商店上架,让你的App走向用户!