请添加图片描述

写在前面

今天我们来实现盲盒App中的商品兑换功能。这是一个非常实用的功能,用户可以将仓库中不想要的商品兑换成潮豆,然后用潮豆去购买其他商品或盲盒。

在电商和游戏类App中,虚拟货币兑换是常见的功能。设计这个功能时,我深刻体会到交互流程设计的重要性。一个好的兑换流程应该:清晰展示兑换信息、支持数量选择、有明确的确认步骤、给用户清晰的反馈。

这篇文章我会详细讲解商品兑换页面的实现,包括商品展示、数量选择器、价格计算、确认对话框等。每段代码都会有详细的解释和设计思路。

功能概述

商品兑换页面包含以下功能:

  • 商品信息展示:显示商品名称、图片、库存数量
  • 选择功能:通过复选框选择要兑换的商品
  • 数量选择器:支持增减数量,不能超过库存
  • 价格显示:实时显示单价和总价
  • 底部操作栏:显示总价和确认按钮
  • 确认对话框:二次确认避免误操作
  • 页面跳转:兑换成功后跳转到成功页面

页面结构设计

首先看整体的页面结构:

import 'package:flutter/material.dart';
import '../../config/app_colors.dart';
import 'exchange_success_page.dart';

class ExchangeProductPage extends StatefulWidget {
  final Map<String, dynamic> product;

  const ExchangeProductPage({super.key, required this.product});

  
  State<ExchangeProductPage> createState() => _ExchangeProductPageState();
}

关键设计点:

  • StatefulWidget:需要管理选择状态和数量
  • product参数:从仓库页面传入要兑换的商品信息
  • required修饰:product是必需参数,不能为空

设计思考:为什么不在这个页面获取商品列表?因为用户是从仓库页面点击"置换"按钮进来的,已经明确了要兑换哪个商品,直接传入更高效。

State类的实现

class _ExchangeProductPageState extends State<ExchangeProductPage> {
  bool _isSelected = false;
  int _quantity = 1;
  final int _chaodouPrice = 28600;

  int get _totalChaodou => _isSelected ? _chaodouPrice * _quantity : 0;

状态变量说明:

  • _isSelected:商品是否被选中,控制复选框状态
  • _quantity:要兑换的数量,默认为1
  • _chaodouPrice:单个商品的潮豆价格,这里固定为28600
  • _totalChaodou:计算属性,根据选中状态和数量计算总价

为什么用getter?

  • 总价是计算出来的,不需要存储
  • 使用getter可以自动响应_isSelected和_quantity的变化
  • 代码更简洁,避免手动更新总价

构建主界面

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 0,
        leading: IconButton(
          icon: const Icon(
            Icons.arrow_back_ios, 
            color: Colors.black, 
            size: 20
          ),
          onPressed: () => Navigator.pop(context),
        ),
        title: const Text(
          '我的仓库',
          style: TextStyle(
            color: Colors.black,
            fontSize: 18,
            fontWeight: FontWeight.w500,
          ),
        ),
        centerTitle: true,
      ),

AppBar的设计:

  • 白色背景:与页面背景一致,简洁统一
  • elevation: 0:去掉阴影,更扁平化
  • iOS风格返回按钮:使用arrow_back_ios图标
  • 标题居中:centerTitle: true
  • 黑色文字:在白色背景上对比度好

平台适配:iOS用户习惯左上角的返回按钮,Android用户习惯左上角的返回箭头。这里使用iOS风格的图标,在两个平台上都能被接受。

      body: Column(
        children: [
          // 商品列表
          Expanded(
            child: ListView(
              children: [
                _buildProductItem(),
              ],
            ),
          ),
          // 底部操作栏
          _buildBottomBar(),
        ],
      ),
    );
  }

布局结构:

  • Column:垂直布局,商品列表在上,操作栏在下
  • Expanded + ListView:商品列表占据剩余空间,可滚动
  • 固定底部栏:操作栏固定在底部,不随列表滚动

为什么用ListView而不是直接放Widget?

  • 虽然现在只有一个商品,但ListView提供了滚动能力
  • 如果商品信息很长,可以滚动查看
  • 为将来扩展多商品兑换预留空间

商品项的实现

  Widget _buildProductItem() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: const BoxDecoration(
        border: Border(
          bottom: BorderSide(color: Color(0xFFF5F5F5), width: 1),
        ),
      ),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 选择框
          GestureDetector(
            onTap: () => setState(() => _isSelected = !_isSelected),
            child: Container(
              width: 22,
              height: 22,
              margin: const EdgeInsets.only(top: 30, right: 8),

商品项的基础结构:

  • Container包裹:提供padding和底部边框
  • Row布局:横向排列选择框、图片、信息
  • crossAxisAlignment.start:顶部对齐
  • GestureDetector:让选择框可点击

选择框的位置

  • margin.top: 30:向下偏移30像素,与商品图片中部对齐
  • margin.right: 8:与图片保持8像素间距
              decoration: BoxDecoration(
                shape: BoxShape.circle,
                color: _isSelected ? AppColors.primary : Colors.white,
                border: Border.all(
                  color: _isSelected 
                    ? AppColors.primary 
                    : Colors.grey.shade400,
                  width: 1.5,
                ),
              ),
              child: _isSelected
                  ? const Icon(Icons.check, size: 14, color: Colors.white)
                  : null,
            ),
          ),

选择框的样式:

  • 圆形:shape: BoxShape.circle
  • 动态颜色:选中时黄色背景,未选中时白色背景
  • 动态边框:选中时黄色边框,未选中时灰色边框
  • 对勾图标:选中时显示白色对勾

交互反馈:选择框的状态变化要明显,让用户清楚知道是否已选中。颜色、边框、图标三重反馈,确保用户不会误操作。

          // 商品图片
          Container(
            width: 100,
            height: 100,
            decoration: BoxDecoration(
              color: const Color(0xFFF8F8F8),
              borderRadius: BorderRadius.circular(8),
            ),
            child: Center(
              child: Icon(
                Icons.shopping_bag_outlined,
                size: 50,
                color: Colors.grey.shade300,
              ),
            ),
          ),
          const SizedBox(width: 12),

商品图片区域:

  • 固定尺寸:100x100像素,正方形
  • 浅灰背景:F8F8F8颜色
  • 圆角:8像素圆角
  • 占位图标:使用购物袋图标作为占位符

为什么不显示真实图片?

  • 这里为了演示简化了,实际项目中应该显示商品图片
  • 可以使用widget.product['image']获取图片URL
  • 建议使用NetworkImage或CachedNetworkImage加载
          // 商品信息
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  widget.product['name'] ?? '江疏影同款潮鞋匡威',
                  style: const TextStyle(
                    fontSize: 14,
                    color: Colors.black87,
                  ),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
                const SizedBox(height: 8),
                Row(
                  children: [
                    Text(
                      '数量:${widget.product['quantity'] ?? 1}',
                      style: TextStyle(
                        fontSize: 13,
                        color: Colors.grey.shade600,
                      ),
                    ),
                    const Spacer(),
                    // 数量选择器
                    _buildQuantitySelector(),
                  ],
                ),

商品信息区域:

  • Expanded:占据剩余宽度
  • 商品名称:14号字体,单行显示,超出省略
  • 库存数量:13号字体,灰色,次要信息
  • 数量选择器:放在右侧,与库存数量同行

数据获取

  • 使用widget.product['name']获取商品名称
  • 使用??运算符提供默认值,避免空值错误
  • 这是Dart的空安全特性,非常实用
                const SizedBox(height: 12),
                Row(
                  children: [
                    Text(
                      '出售价格:',
                      style: TextStyle(
                        fontSize: 13,
                        color: Colors.grey.shade600,
                      ),
                    ),
                    Text(
                      '$_chaodouPrice',
                      style: const TextStyle(
                        fontSize: 18,
                        color: Color(0xFFB8860B),
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    const SizedBox(width: 4),
                    const Text(
                      '潮豆',
                      style: TextStyle(
                        fontSize: 13,
                        color: Color(0xFFB8860B),
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

价格显示:

  • 标签文字:“出售价格:”,13号灰色字体
  • 价格数字:18号金色字体,加粗,突出显示
  • 单位文字:“潮豆”,13号金色字体
  • 金色主题:使用B8860B颜色,代表金币/货币

视觉层次:价格是最重要的信息,所以用最大的字号和最醒目的颜色。标签和单位用较小的字号,形成层次感。

数量选择器实现

这是一个非常实用的组件,很多电商App都有类似的设计。

  Widget _buildQuantitySelector() {
    return Container(
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey.shade300),
        borderRadius: BorderRadius.circular(4),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          // 减少按钮
          GestureDetector(
            onTap: () {
              if (_quantity > 1) {
                setState(() => _quantity--);
              }
            },

选择器的基础结构:

  • Container包裹:提供边框和圆角
  • Row布局:横向排列减号、数字、加号
  • mainAxisSize.min:宽度自适应内容
  • GestureDetector:让按钮可点击

减少按钮的逻辑

  • 判断_quantity > 1:数量不能小于1
  • 使用setState更新状态
  • 使用--运算符减1
            child: Container(
              width: 28,
              height: 28,
              decoration: BoxDecoration(
                border: Border(
                  right: BorderSide(color: Colors.grey.shade300),
                ),
              ),
              child: Center(
                child: Text(
                  '−',
                  style: TextStyle(
                    fontSize: 16,
                    color: _quantity > 1 
                      ? Colors.black54 
                      : Colors.grey.shade300,
                  ),
                ),
              ),
            ),
          ),

减号按钮的样式:

  • 固定尺寸:28x28像素,正方形
  • 右边框:与数字区域分隔
  • 动态颜色:可用时黑色,不可用时灰色
  • 减号符号:使用Unicode字符’−’

用户体验细节

  • 当数量为1时,减号按钮变灰,提示用户不能再减
  • 但按钮仍然可以点击,只是不会有效果
  • 这比禁用按钮更友好,用户可以尝试点击
          // 数量显示
          Container(
            width: 36,
            height: 28,
            color: Colors.white,
            child: Center(
              child: Text(
                '$_quantity',
                style: const TextStyle(
                  fontSize: 14,
                  color: Colors.black87,
                ),
              ),
            ),
          ),

数字显示区域:

  • 宽度36:比按钮稍宽,容纳两位数
  • 白色背景:与按钮区分
  • 居中显示:数字在中间
  • 14号字体:清晰可读
          // 增加按钮
          GestureDetector(
            onTap: () {
              final maxQuantity = widget.product['quantity'] ?? 1;
              if (_quantity < maxQuantity) {
                setState(() => _quantity++);
              }
            },
            child: Container(
              width: 28,
              height: 28,
              decoration: BoxDecoration(
                border: Border(
                  left: BorderSide(color: Colors.grey.shade300),
                ),
              ),
              child: const Center(
                child: Text(
                  '+',
                  style: TextStyle(
                    fontSize: 16,
                    color: Colors.black54,
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

加号按钮的逻辑:

  • 获取最大数量:从商品数据中获取库存
  • 边界检查:不能超过库存数量
  • 左边框:与数字区域分隔
  • 加号符号:使用’+'字符

业务逻辑:兑换数量不能超过库存,这是基本的业务规则。在增加数量时必须检查,避免用户选择超出库存的数量。

底部操作栏实现

  Widget _buildBottomBar() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      decoration: BoxDecoration(
        color: Colors.white,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.05),
            blurRadius: 10,
            offset: const Offset(0, -2),
          ),
        ],
      ),

底部栏的样式:

  • 白色背景:与页面背景一致
  • 上方阴影:offset为(0, -2),阴影在上方
  • 轻微阴影:opacity为0.05,不会太重
  • 内边距:水平16,垂直12

为什么阴影在上方?

  • 底部栏固定在底部,需要与上方内容区分
  • 上方阴影可以营造"浮起"的效果
  • 这是Material Design的常见做法
      child: SafeArea(
        child: Row(
          children: [
            // 总价
            Expanded(
              child: Row(
                children: [
                  const Text(
                    '总价:',
                    style: TextStyle(
                      fontSize: 14,
                      color: Colors.black87,
                    ),
                  ),
                  Text(
                    '$_totalChaodou',
                    style: const TextStyle(
                      fontSize: 18,
                      color: Color(0xFFB8860B),
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const Text(
                    '潮豆',
                    style: TextStyle(
                      fontSize: 14,
                      color: Color(0xFFB8860B),
                    ),
                  ),
                ],
              ),
            ),
            // 确认置换按钮
            GestureDetector(
              onTap: _isSelected ? _showConfirmDialog : null,
              child: Container(
                padding: const EdgeInsets.symmetric(
                  horizontal: 24, 
                  vertical: 12
                ),
                decoration: BoxDecoration(
                  color: _isSelected 
                    ? AppColors.primary 
                    : Colors.grey.shade300,
                  borderRadius: BorderRadius.circular(4),
                ),
                child: const Text(
                  '确认置换',
                  style: TextStyle(
                    fontSize: 16,
                    color: Colors.white,
                    fontWeight: FontWeight.w500,
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

底部栏的内容:

  • SafeArea包裹:避免被底部导航栏遮挡
  • Row布局:总价在左,按钮在右
  • Expanded:让总价区域占据剩余空间
  • 动态按钮:选中时黄色可点击,未选中时灰色不可点击

按钮状态管理

  • onTap: _isSelected ? _showConfirmDialog : null
  • 未选中时onTap为null,按钮不响应点击
  • 选中时调用_showConfirmDialog显示确认对话框
  • 颜色也随状态变化,给用户明确的视觉反馈

确认对话框实现

  void _showConfirmDialog() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(16)
        ),
        title: const Text('确认置换'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('确定要置换「${widget.product['name']}」吗?'),
            const SizedBox(height: 12),
            Row(
              children: [
                const Text('置换数量:'),
                Text(
                  '$_quantity', 
                  style: const TextStyle(fontWeight: FontWeight.bold)
                ),
              ],
            ),
            const SizedBox(height: 8),
            Row(
              children: [
                const Text('获得潮豆:'),
                Text(
                  '$_totalChaodou',
                  style: const TextStyle(
                    color: Color(0xFFB8860B),
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
          ],
        ),

确认对话框的设计:

  • 圆角对话框:16像素圆角,现代风格
  • 清晰的标题:“确认置换”
  • 详细的信息:商品名称、置换数量、获得潮豆
  • Column布局:信息垂直排列
  • mainAxisSize.min:高度自适应内容

信息展示的重点

  • 商品名称用引号包裹,更醒目
  • 数量和潮豆用加粗字体突出
  • 潮豆数字用金色,与主题一致
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text(
              '取消', 
              style: TextStyle(color: Colors.grey)
            ),
          ),
          TextButton(
            onPressed: () {
              Navigator.pop(context);
              // 跳转到成功页面
              Navigator.pushReplacement(
                context,
                MaterialPageRoute(
                  builder: (_) => const ExchangeSuccessPage()
                ),
              );
            },
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }
}

对话框按钮:

  • 取消按钮:灰色文字,关闭对话框
  • 确定按钮:主题色文字,执行置换操作
  • pushReplacement:替换当前页面,不能返回

为什么用pushReplacement?

  • 置换成功后,不应该让用户返回到置换页面
  • 使用pushReplacement替换当前页面
  • 用户只能从成功页面返回到仓库页面

用户体验:置换是不可逆操作,成功后不应该让用户返回重复操作。这是很多交易类App的通用做法。

踩过的坑

1. 数量选择器的边界问题

问题:最初没有限制最大数量,用户可以选择超过库存的数量。

解决

if (_quantity < maxQuantity) {
  setState(() => _quantity++);
}

教训:任何涉及数量的操作都要做边界检查,包括最小值和最大值。

2. 未选中时按钮仍可点击

问题:未选中商品时,确认按钮仍然可以点击,导致逻辑错误。

解决

onTap: _isSelected ? _showConfirmDialog : null,

教训:按钮的可用状态要与业务逻辑一致,不可用时应该禁用。

3. 总价计算错误

问题:最初总价没有考虑选中状态,未选中时也显示价格。

解决

int get _totalChaodou => _isSelected ? _chaodouPrice * _quantity : 0;

教训:计算属性要考虑所有相关状态,不能只考虑数量。

4. 页面跳转后数据未更新

问题:置换成功后返回仓库页面,数据没有更新。

解决:在仓库页面接收返回值并更新数据:

final result = await Navigator.push(...);
if (result != null && result['exchanged'] == true) {
  setState(() {
    // 更新数据
  });
}

教训:页面间的数据传递要完整,包括去和回两个方向。

功能扩展建议

1. 批量兑换

支持一次选择多个商品兑换:

List<Map<String, dynamic>> _selectedProducts = [];

void _toggleProduct(Map<String, dynamic> product) {
  setState(() {
    if (_selectedProducts.contains(product)) {
      _selectedProducts.remove(product);
    } else {
      _selectedProducts.add(product);
    }
  });
}

2. 兑换记录

显示历史兑换记录:

class ExchangeHistory extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: records.length,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text(records[index]['name']),
          subtitle: Text('兑换时间:${records[index]['time']}'),
          trailing: Text('+${records[index]['chaodou']} 潮豆'),
        );
      },
    );
  }
}

3. 价格波动提示

如果兑换价格会变化,应该提示用户:

if (currentPrice != originalPrice) {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: Text('价格变动提示'),
      content: Text('当前价格已变更为 $currentPrice 潮豆'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: Text('知道了'),
        ),
      ],
    ),
  );
}

4. 兑换限制

添加每日兑换限制:

class ExchangeLimit {
  static const int dailyLimit = 10;
  static int todayCount = 0;
  
  static bool canExchange() {
    return todayCount < dailyLimit;
  }
  
  static void recordExchange() {
    todayCount++;
  }
}

写在最后

商品兑换功能是盲盒App中重要的变现环节,通过这个功能的实现,我们学习了:

  1. 数量选择器:实现了一个通用的加减数量组件
  2. 状态管理:使用bool和int管理选择和数量状态
  3. 计算属性:使用getter实现自动计算的总价
  4. 边界检查:确保数量在合理范围内
  5. 确认流程:通过对话框实现二次确认
  6. 页面跳转:使用pushReplacement避免返回
  7. 动态样式:根据状态改变按钮颜色和可用性

开发心得

在实现兑换功能时,我最大的感受是细节决定成败。比如:

  • 数量选择器的边界检查
  • 未选中时按钮的禁用
  • 确认对话框的信息展示
  • 页面跳转的方式选择

这些细节虽然不复杂,但如果处理不当,会严重影响用户体验。

技术亮点

数量选择器是这个页面的技术亮点。它不仅要支持加减操作,还要:

  • 限制最小值和最大值
  • 根据状态改变样式
  • 实时更新总价
  • 提供清晰的视觉反馈

这是一个可以复用的组件,可以提取出来在其他地方使用。

业务逻辑

兑换功能涉及的业务逻辑:

  1. 检查商品是否可兑换
  2. 验证兑换数量是否合法
  3. 计算兑换获得的潮豆
  4. 更新用户的商品和潮豆余额
  5. 记录兑换历史

在实际项目中,这些逻辑应该在后端实现,前端只负责展示和交互。

与OpenHarmony的适配

这个页面的代码完全兼容OpenHarmony平台。所有的UI组件和交互逻辑都是Flutter标准实现,可以无缝运行在OpenHarmony上。

后续优化方向

  1. 支持批量兑换多个商品
  2. 添加兑换记录查询功能
  3. 实现价格波动提示
  4. 添加每日兑换限制
  5. 优化加载和错误处理

希望这篇文章能帮助你理解商品兑换功能的实现。下一篇文章我们将实现兑换成功页面,展示兑换结果并引导用户进行下一步操作!


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐