Flutter for Openharmony盲盒抽奖App应用实战+订单确认实现
从购物车到支付,中间有个很重要的环节——订单确认页。这个页面看起来简单,就是让用户确认一下商品、地址、价格,但实际做起来发现有不少细节要处理。地址展示、价格计算、支付方式选择、协议勾选,每个环节都要考虑周全。我在做这个页面时,特别注意了信息的层次感。用户进来后,应该一眼就能看到最重要的信息:收货地址、商品清单、总价。其他的信息作为辅助,不能喧宾夺主。@override这是标准的 StatefulW

写在前面
从购物车到支付,中间有个很重要的环节——订单确认页。这个页面看起来简单,就是让用户确认一下商品、地址、价格,但实际做起来发现有不少细节要处理。地址展示、价格计算、支付方式选择、协议勾选,每个环节都要考虑周全。
我在做这个页面时,特别注意了信息的层次感。用户进来后,应该一眼就能看到最重要的信息:收货地址、商品清单、总价。其他的信息作为辅助,不能喧宾夺主。
订单确认页的功能规划
在动手之前,我先梳理了一下订单确认页需要做什么。作为支付前的最后一步,这个页面的核心是:让用户清楚地知道自己要买什么、花多少钱、送到哪里。
主要功能:
- 收货地址展示和修改
- 商品信息列表
- 价格明细(商品总价、运费、优惠券、合计)
- 支付方式选择(微信、支付宝)
- 购买协议勾选
- 确认支付按钮
整个页面的信息量不小,但要做到清晰明了,不能让用户觉得混乱。
页面结构搭建
页面类的定义
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;
价格计算的步骤:
- 用
fold计算商品总价:遍历所有商品,累加价格 × 数量- 运费固定10元(实际项目中应该根据地址和重量计算)
- 优惠券固定50元(实际项目中应该从用户选择的优惠券获取)
- 合计 = 商品总价 + 运费 - 优惠券
这里用的是硬编码的运费和优惠券,实际项目中应该动态获取。
价格明细的容器
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),
写在最后
订单确认页是支付流程中很重要的一环,必须做到信息清晰、操作简单。用户在这个页面停留的时间不会太长,但如果信息展示不清楚,很容易导致用户放弃支付。
我的设计原则:
-
信息层次要清晰。收货地址、商品清单、价格明细、支付方式,每个模块都用白色卡片包裹,一眼就能区分。
-
重要信息要突出。合计金额用粗体和红色,支付按钮用金色渐变,这些都是为了吸引用户注意。
-
交互要简单。支付方式点击切换,协议点击勾选,不需要复杂的操作。
-
细节要统一。圆角、间距、阴影都保持一致,让整个页面看起来协调。
做完订单确认页后,我拿给几个朋友试用。有人说"信息很清楚,一眼就能看懂",有人说"支付按钮很醒目,不会找不到"。这些反馈让我觉得,那些细节的打磨是值得的。
下一步计划:
订单确认页做完了,接下来要做支付页和支付成功页。支付页会调用真实的支付接口,支付成功页会有一些动画效果。不过有了前面的基础,应该会更容易。
如果你也在做类似的项目,希望这篇文章能给你一些启发。代码不是最重要的,重要的是理解背后的设计思路和用户体验的考量。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)