🛒 24 - 电商App

阶段五:实战项目 · 第24课
✅ 验证通过

📖 核心概念

电商App是Flutter最复杂的实战项目之一。它涉及商品展示、分类浏览、搜索过滤、购物车管理、结算支付、订单跟踪等完整购物流程。本课将用Provider管理全局状态,构建一个多页面电商App,重点攻克购物车状态同步和复杂列表渲染。

🛍️ 商品数据模型

1. 商品、购物车与订单模型

// 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 }

🛒 购物车状态管理

2. CartProvider - 核心业务逻辑

// 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();
  }
}

📱 商品列表与搜索

3. 商品列表页

// 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]),
          );
        },
      ),
    );
  }
}

4. 商品卡片组件

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,
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

5. 购物车页面

// 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})'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
✓ 商品列表:网格布局+无限滚动加载 ✓ 搜索过滤:关键词+分类+排序 ✓ 商品详情:图片轮播+规格选择+加购 ✓ 购物车:数量增减+变体区分+合计计算 ✓ 优惠计算:满减+运费+折扣自动计算 ✓ 结算流程:地址选择→确认订单→支付

📝 练习

  1. 实现商品收藏功能——心形动画+收藏列表页
  2. 添加规格选择器——点击商品进入规格选择弹窗,不同规格可能不同价格
  3. 实现订单列表与状态跟踪——查看历史订单和物流信息
  4. 添加商品评价系统——星级评分+文字评论+图片上传

🏆 成就解锁:商业先锋

你已构建了一个电商App!掌握了:

下一课预告:发布与打包——签名配置、渠道包、CI/CD自动化、商店上架,让你的App走向用户!