请添加图片描述

写在前面

从购物车到支付,中间有个很重要的环节——订单确认页。这个页面看起来简单,就是让用户确认一下商品、地址、价格,但实际做起来发现有不少细节要处理。地址展示、价格计算、支付方式选择、协议勾选,每个环节都要考虑周全。

我在做这个页面时,特别注意了信息的层次感。用户进来后,应该一眼就能看到最重要的信息:收货地址、商品清单、总价。其他的信息作为辅助,不能喧宾夺主。

订单确认页的功能规划

在动手之前,我先梳理了一下订单确认页需要做什么。作为支付前的最后一步,这个页面的核心是:让用户清楚地知道自己要买什么、花多少钱、送到哪里

主要功能:

  • 收货地址展示和修改
  • 商品信息列表
  • 价格明细(商品总价、运费、优惠券、合计)
  • 支付方式选择(微信、支付宝)
  • 购买协议勾选
  • 确认支付按钮

整个页面的信息量不小,但要做到清晰明了,不能让用户觉得混乱。

页面结构搭建

页面类的定义

class ConfirmOrderPage extends StatefulWidget {
  const ConfirmOrderPage({super.key});

  
  State<ConfirmOrderPage> createState() => _ConfirmOrderPageState();
}

这是标准的 StatefulWidget,因为页面有支付方式选择、协议勾选等状态需要管理。

状态变量的初始化

class _ConfirmOrderPageState extends State<ConfirmOrderPage> {
  final _cartService = CartService();
  String _selectedPaymentMethod = 'wechat';
  bool _agreeTerms = true;

状态变量的设计:

  • _cartService:购物车服务,用来获取选中的商品
  • _selectedPaymentMethod:当前选中的支付方式,默认微信支付
  • _agreeTerms:是否同意购买协议,默认true

为什么协议默认勾选?这是电商App的常见做法,减少用户操作步骤。但要确保协议内容清晰可见,不能误导用户。

页面布局结构


Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('确认订单'),
      centerTitle: true,
      elevation: 0,
    ),
    body: SingleChildScrollView(
      child: Column(
        children: [
          _buildAddressSection(),
          _buildProductSection(),
          _buildPriceSection(),
          _buildPaymentSection(),
          _buildTermsSection(),
          const SizedBox(height: 80),
        ],
      ),
    ),
    bottomNavigationBar: _buildBottomBar(),
  );
}

布局思路:

SingleChildScrollView 让内容可以滚动,因为商品多的时候会超出屏幕。

底部用 bottomNavigationBar 固定支付按钮,这样用户滚动时按钮始终可见,随时可以点击支付。

最后加个 SizedBox(height: 80),避免内容被底部按钮遮挡。

收货地址模块

地址卡片的容器

Widget _buildAddressSection() {
  return Container(
    margin: const EdgeInsets.all(12),
    padding: const EdgeInsets.all(16),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12),
      boxShadow: [
        BoxShadow(
          color: Colors.black.withOpacity(0.05),
          blurRadius: 8,
        ),
      ],
    ),

地址卡片用白色背景,配上很浅的阴影(opacity: 0.05),让它从页面背景中凸显出来。圆角12是现代App的标准圆角,看起来舒服。

地址标题栏

const Row(
  children: [
    Icon(Icons.location_on, size: 20, color: Color(0xFFFFD700)),
    SizedBox(width: 8),
    Text('收货地址', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
  ],
),

用一个金色的定位图标,配上"收货地址"文字。图标不大(size: 20),但颜色醒目,能吸引用户注意。

为什么用金色?因为整个App的主题色就是金色,保持一致性。

地址信息展示

const Text(
  '张三',
  style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
const Text(
  '13800138000',
  style: TextStyle(fontSize: 12, color: Color(0xFF999999)),
),
const SizedBox(height: 8),
const Text(
  '北京市朝阳区建国路1号',
  style: TextStyle(fontSize: 12, color: Color(0xFF666666)),
),

地址信息的层次:

  • 收货人姓名用粗体,最醒目
  • 电话号码用小字号和灰色,次要信息
  • 详细地址用稍深的灰色,让用户能清楚看到

每个信息之间用 SizedBox 隔开,不会挤在一起。

修改地址入口

GestureDetector(
  onTap: () {},
  child: const Text(
    '修改地址',
    style: TextStyle(fontSize: 12, color: Color(0xFFFFD700)),
  ),
),

"修改地址"用金色文字,表示这是一个可点击的链接。字号12,不会太抢眼,但用户需要时能找到。

实际项目中,点击这里应该跳转到地址列表页,让用户选择或添加新地址。

商品信息模块

获取选中的商品

Widget _buildProductSection() {
  final selectedItems = _cartService.items.where((item) => item.isSelected).toList();

从购物车服务中获取选中的商品。用 where 过滤,只显示用户勾选的商品。

为什么要过滤?因为购物车里可能有很多商品,但用户可能只选了其中几个结算。

商品列表的容器

return Container(
  margin: const EdgeInsets.symmetric(horizontal: 12),
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.05),
        blurRadius: 8,
      ),
    ],
  ),

商品列表的容器样式跟地址卡片一样,保持视觉一致性。

商品列表的标题

const Text('商品信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),

简单的标题,告诉用户这里是商品信息。加粗显示,跟地址标题的样式一致。

商品项的循环渲染

...selectedItems.asMap().entries.map((entry) {
  final isLast = entry.key == selectedItems.length - 1;
  return Column(
    children: [
      _buildProductItem(entry.value),
      if (!isLast) const Divider(height: 16),
    ],
  );
}).toList(),

为什么用 asMap().entries

因为需要知道当前是不是最后一个商品。如果不是最后一个,就加一条分割线;如果是最后一个,就不加。

这样商品之间有分割线,但最后一个商品下面没有,看起来更整洁。

单个商品项的布局

Widget _buildProductItem(CartItem item) {
  return Row(
    children: [
      NetworkImageWithLoading(
        imageUrl: item.image,
        width: 60,
        height: 60,
        borderRadius: BorderRadius.circular(8),
      ),
      const SizedBox(width: 12),
      Expanded(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)),
            const SizedBox(height: 4),
            Text(
              ${item.price.toStringAsFixed(2)}',
              style: const TextStyle(color: Color(0xFFFF6B6B), fontWeight: FontWeight.bold),
            ),
          ],
        ),
      ),
      Text(${item.quantity}', style: const TextStyle(color: Color(0xFF999999))),
    ],
  );
}

商品项的布局:

  • 左边是商品图片,60x60的正方形,带圆角
  • 中间是商品名称和价格,用 Expanded 占满剩余空间
  • 右边是数量,用灰色显示

价格用红色(0xFFFF6B6B),这是电商App的惯例,能吸引用户注意。

价格明细模块

价格计算逻辑

Widget _buildPriceSection() {
  final selectedItems = _cartService.items.where((item) => item.isSelected).toList();
  final subtotal = selectedItems.fold<double>(
    0,
    (sum, item) => sum + (item.price * item.quantity),
  );
  final shipping = 10.0;
  final discount = 50.0;
  final total = subtotal + shipping - discount;

价格计算的步骤:

  1. fold 计算商品总价:遍历所有商品,累加 价格 × 数量
  2. 运费固定10元(实际项目中应该根据地址和重量计算)
  3. 优惠券固定50元(实际项目中应该从用户选择的优惠券获取)
  4. 合计 = 商品总价 + 运费 - 优惠券

这里用的是硬编码的运费和优惠券,实际项目中应该动态获取。

价格明细的容器

return Container(
  margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.05),
        blurRadius: 8,
      ),
    ],
  ),

价格明细的容器样式跟前面的卡片一样,保持一致性。

价格明细的列表

child: Column(
  children: [
    _buildPriceRow('商品总价', ${subtotal.toStringAsFixed(2)}'),
    const SizedBox(height: 8),
    _buildPriceRow('运费', ${shipping.toStringAsFixed(2)}'),
    const SizedBox(height: 8),
    _buildPriceRow('优惠券', '-¥${discount.toStringAsFixed(2)}', color: const Color(0xFFFF6B6B)),
    const Divider(height: 16),
    _buildPriceRow(
      '合计',
      ${total.toStringAsFixed(2)}',
      isBold: true,
      color: const Color(0xFFFF6B6B),
    ),
  ],
),

价格明细的展示:

  • 商品总价、运费用默认样式
  • 优惠券用红色,前面加个负号,表示减免
  • Divider 分割线把合计跟其他项分开
  • 合计用粗体和红色,最醒目

每项之间用 SizedBox(height: 8) 隔开,不会挤在一起。

价格行的封装

Widget _buildPriceRow(String label, String value, {bool isBold = false, Color? color}) {
  return Row(
    children: [
      Text(label, style: TextStyle(fontWeight: isBold ? FontWeight.bold : FontWeight.normal)),
      const Spacer(),
      Text(
        value,
        style: TextStyle(
          fontWeight: isBold ? FontWeight.bold : FontWeight.normal,
          color: color,
        ),
      ),
    ],
  );
}

把价格行封装成一个方法,避免重复代码。

参数 isBold 控制是否加粗,color 控制文字颜色。这样一个方法就能应对所有场景。

支付方式选择

支付方式的容器

Widget _buildPaymentSection() {
  return Container(
    margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
    padding: const EdgeInsets.all(16),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12),
      boxShadow: [
        BoxShadow(
          color: Colors.black.withOpacity(0.05),
          blurRadius: 8,
        ),
      ],
    ),

支付方式的容器样式跟前面的卡片一样,保持一致性。

支付方式的标题

const Text('支付方式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),

简单的标题,告诉用户这里是支付方式选择。

支付方式的选项

_buildPaymentOption('wechat', '微信支付', Icons.payment),
const SizedBox(height: 12),
_buildPaymentOption('alipay', '支付宝', Icons.account_balance_wallet),

提供两种支付方式:微信支付和支付宝。每个选项之间用 SizedBox(height: 12) 隔开。

支付选项的实现

Widget _buildPaymentOption(String value, String label, IconData icon) {
  final isSelected = _selectedPaymentMethod == value;
  return GestureDetector(
    onTap: () => setState(() => _selectedPaymentMethod = value),
    child: Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        border: Border.all(
          color: isSelected ? const Color(0xFFFFD700) : const Color(0xFFDDDDDD),
        ),
        borderRadius: BorderRadius.circular(8),
      ),

选项的交互:

点击选项时,调用 setState 更新 _selectedPaymentMethod,触发重新构建。

选中的选项用金色边框,未选中的用灰色边框。这样用户一眼就能看出哪个是选中的。

支付选项的内容

child: Row(
  children: [
    Icon(icon, size: 24, color: const Color(0xFFFFD700)),
    const SizedBox(width: 12),
    Text(label, style: const TextStyle(fontWeight: FontWeight.w500)),
    const Spacer(),
    Container(
      width: 20,
      height: 20,
      decoration: BoxDecoration(
        border: Border.all(
          color: isSelected ? const Color(0xFFFFD700) : const Color(0xFFDDDDDD),
        ),
        shape: BoxShape.circle,
      ),
      child: isSelected
          ? const Icon(Icons.check, size: 14, color: Color(0xFFFFD700))
          : null,
    ),
  ],
),

选项的布局:

  • 左边是支付方式的图标,金色显示
  • 中间是支付方式的名称
  • 右边是单选按钮,选中时显示对勾

Spacer() 让单选按钮始终在右边,不管名称有多长。

购买协议模块

协议勾选的实现

Widget _buildTermsSection() {
  return Container(
    margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
    padding: const EdgeInsets.all(12),
    child: Row(
      children: [
        GestureDetector(
          onTap: () => setState(() => _agreeTerms = !_agreeTerms),
          child: Container(
            width: 20,
            height: 20,
            decoration: BoxDecoration(
              color: _agreeTerms ? const Color(0xFFFFD700) : Colors.white,
              border: Border.all(
                color: _agreeTerms ? const Color(0xFFFFD700) : const Color(0xFFDDDDDD),
              ),
              borderRadius: BorderRadius.circular(4),
            ),
            child: _agreeTerms
                ? const Icon(Icons.check, size: 14, color: Colors.white)
                : null,
          ),
        ),

勾选框的交互:

点击勾选框时,调用 setState 切换 _agreeTerms 的值。

勾选时,背景变成金色,显示白色对勾;未勾选时,背景是白色,只有灰色边框。

协议文字

const SizedBox(width: 8),
const Expanded(
  child: Text(
    '我已阅读并同意《购买协议》',
    style: TextStyle(fontSize: 12, color: Color(0xFF999999)),
  ),
),

协议文字用小字号和灰色,不会太抢眼。用 Expanded 让文字能自动换行,不会溢出。

实际项目中,"《购买协议》"应该是一个可点击的链接,点击后显示协议详情。

底部支付按钮

底部栏的容器

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

底部栏用白色背景,配上向上的阴影(offset: Offset(0, -2)),让它看起来浮在内容上方。

支付按钮的实现

child: GestureDetector(
  onTap: () {
    Navigator.pushNamed(context, '/payment_success');
  },
  child: Container(
    height: 50,
    decoration: BoxDecoration(
      gradient: const LinearGradient(
        colors: [Color(0xFFFFD700), Color(0xFFFFA500)],
      ),
      borderRadius: BorderRadius.circular(25),
    ),
    child: const Center(
      child: Text(
        '确认支付',
        style: TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.bold,
          fontSize: 16,
        ),
      ),
    ),
  ),
),

支付按钮的设计:

  • 用金色渐变背景,跟App主题一致
  • 圆角25,做成胶囊形状,更有点击欲望
  • 文字白色加粗,醒目
  • 高度50,足够大,方便点击

点击按钮后跳转到支付成功页。实际项目中,这里应该先调用支付接口,成功后再跳转。

一些细节优化

卡片间距的统一

margin: const EdgeInsets.all(12),  // 地址卡片
margin: const EdgeInsets.symmetric(horizontal: 12),  // 商品卡片
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),  // 价格卡片

所有卡片的左右间距都是12,保持一致。上下间距根据需要调整,但都是8的倍数(8、12、16),看起来更整齐。

圆角的统一

borderRadius: BorderRadius.circular(12),  // 卡片圆角
borderRadius: BorderRadius.circular(8),   // 图片和选项圆角
borderRadius: BorderRadius.circular(25),  // 按钮圆角
borderRadius: BorderRadius.circular(4),   // 勾选框圆角

不同元素用不同的圆角,但都是4的倍数。大元素用大圆角,小元素用小圆角,形成层次感。

阴影的统一

BoxShadow(
  color: Colors.black.withOpacity(0.05),  // 卡片阴影
  blurRadius: 8,
),
BoxShadow(
  color: Colors.black.withOpacity(0.1),   // 底部栏阴影
  blurRadius: 10,
  offset: const Offset(0, -2),
),

卡片用很浅的阴影(opacity: 0.05),只是为了增加层次感。

底部栏用稍深的阴影(opacity: 0.1),让它看起来浮在内容上方。

踩过的坑

价格计算精度问题

一开始我直接用 double 计算价格,结果出现了 59.99999999 这种情况。

解决方案:

toStringAsFixed(2) 格式化价格,保留两位小数:

Text(${subtotal.toStringAsFixed(2)}')

实际项目中,涉及金额的计算应该用 Decimal 库,避免浮点数精度问题。

底部按钮被遮挡

一开始我没有在内容底部加 SizedBox,结果最后一个模块被底部按钮遮挡了。

解决方案:

在内容最后加个 SizedBox(height: 80),给底部按钮留出空间:

Column(
  children: [
    // ... 各个模块
    const SizedBox(height: 80),
  ],
)

支付方式选择不生效

一开始我忘了在点击时调用 setState,结果点击支付方式后界面没有更新。

解决方案:

onTap 里调用 setState

onTap: () => setState(() => _selectedPaymentMethod = value),

这是 Flutter 的基本功,状态变化后必须调用 setState 才会重新构建。

商品列表分割线多余

一开始我给每个商品都加了分割线,结果最后一个商品下面也有分割线,看起来很奇怪。

解决方案:

asMap().entries 判断是否是最后一个商品:

final isLast = entry.key == selectedItems.length - 1;
if (!isLast) const Divider(height: 16),

写在最后

订单确认页是支付流程中很重要的一环,必须做到信息清晰、操作简单。用户在这个页面停留的时间不会太长,但如果信息展示不清楚,很容易导致用户放弃支付。

我的设计原则:

  1. 信息层次要清晰。收货地址、商品清单、价格明细、支付方式,每个模块都用白色卡片包裹,一眼就能区分。

  2. 重要信息要突出。合计金额用粗体和红色,支付按钮用金色渐变,这些都是为了吸引用户注意。

  3. 交互要简单。支付方式点击切换,协议点击勾选,不需要复杂的操作。

  4. 细节要统一。圆角、间距、阴影都保持一致,让整个页面看起来协调。

做完订单确认页后,我拿给几个朋友试用。有人说"信息很清楚,一眼就能看懂",有人说"支付按钮很醒目,不会找不到"。这些反馈让我觉得,那些细节的打磨是值得的。

下一步计划:

订单确认页做完了,接下来要做支付页和支付成功页。支付页会调用真实的支付接口,支付成功页会有一些动画效果。不过有了前面的基础,应该会更容易。

如果你也在做类似的项目,希望这篇文章能给你一些启发。代码不是最重要的,重要的是理解背后的设计思路和用户体验的考量。


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

Logo

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

更多推荐