
写在前面
步数追踪是健康管理App的核心功能之一。每天走了多少步、消耗了多少卡路里,这些数据能直观地反映用户的运动情况。今天我们来实现一个功能完整的步数卡片组件,包含进度展示、快捷添加按钮和自定义输入功能。
这个卡片的设计思路是:顶部显示标题和消耗的卡路里,中间用大字号展示当前步数和目标,下方是进度条,最底部放几个快捷操作按钮。用户可以快速添加固定步数,也可以点击编辑按钮手动输入。
导入依赖
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../utils/colors.dart';
import '../l10n/app_localizations.dart';
import '../providers/user_provider.dart';
关于这些导入的说明:
这里用到了 provider 包来管理状态。步数数据存在 UserProvider 里,卡片组件通过 Consumer 监听数据变化,实现自动刷新。
colors.dart 和 app_localizations.dart 分别提供主题色和多语言支持,这两个在整个App里都会用到。
组件结构
步数卡片是纯展示+交互组件,不需要维护自己的状态,所以用 StatelessWidget:
class StepsCard extends StatelessWidget {
const StepsCard({super.key});
static const int dailyGoal = 10000;
常量定义的设计:
每日目标设成10000步,这是个比较通用的健康标准。用 static const 定义,编译时就确定了值,性能更好。
如果将来要支持用户自定义目标,可以把这个值改成从Provider获取。这样的设计让代码更灵活。
构建主体UI
build 方法里先获取主题相关的变量:
Widget build(BuildContext context) {
final colors = context.appColors;
final l10n = context.l10n;
final isDark = Theme.of(context).brightness == Brightness.dark;
final primaryColor = isDark ? AppColors.primaryLight : AppColors.primary;
主题变量的获取:
context.appColors 是我们定义的扩展方法,能方便地获取当前主题的颜色配置。深色模式下主色调要用 primaryLight,这样在深色背景上才能看清楚。
提前定义 primaryColor 变量,避免后面重复的三元表达式判断,代码更清晰。
接下来用 Consumer 包裹整个卡片,监听 UserProvider 的变化:
return Consumer<UserProvider>(
builder: (context, provider, _) {
final steps = provider.todayRecord.steps;
final progress = (steps / dailyGoal).clamp(0.0, 1.0);
final calories = (steps * 0.04).round();
数据获取和计算:
从 provider.todayRecord 里取出今天的步数。进度值用 clamp 限制在0到1之间,防止超过100%时进度条溢出。
卡路里的计算用了一个简单的公式:每步大约消耗0.04卡路里。这个数值因人而异,但作为估算值足够用了。实际应用中可以根据用户的体重、身高等因素调整这个系数。
卡片容器
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colors.cardBackground,
borderRadius: BorderRadius.circular(20),
),
容器的设计:
20像素的内边距,20像素的圆角,这是整个App卡片的统一风格。背景色用 colors.cardBackground,会根据当前主题自动切换。
这样的设计保持了整个App的视觉一致性,用户能够快速识别这是一个卡片组件。
标题栏
标题栏左边是"步数"文字,右边是消耗的卡路里:
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.steps,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: primaryColor,
),
),
标题的国际化:
l10n.steps 会根据当前语言返回"步数"或"Steps"。这样可以支持多语言,用户能够看到自己熟悉的语言。
卡路里显示用一个带背景的小标签:
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: primaryColor.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.local_fire_department,
size: 16,
color: isDark ? AppColors.orangeLight : AppColors.orange,
),
const SizedBox(width: 4),
Text(
'$calories Cal',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: colors.textPrimary,
),
),
],
),
),
卡路里标签的设计:
火焰图标配橙色,很直观地表示"燃烧卡路里"。深色模式下背景透明度调高一点(0.2),浅色模式下用0.1,这样视觉效果更协调。
标签的内边距是水平12像素、垂直4像素,这样看起来紧凑但又不会太拥挤。
步数显示
中间区域用大字号展示当前步数:
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatSteps(steps),
style: TextStyle(
fontSize: 42,
fontWeight: FontWeight.bold,
color: primaryColor,
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'/ ${_formatSteps(dailyGoal)}',
style: TextStyle(fontSize: 16, color: colors.textSecondary),
),
),
],
),
步数显示的设计:
crossAxisAlignment: CrossAxisAlignment.end 让两个文本底部对齐。目标步数用较小的字号和次要颜色,形成主次分明的视觉层次。
当前步数用42号大字体,这是卡片的视觉焦点,用户一眼就能看到。
步数格式化方法把大数字转成更易读的形式:
String _formatSteps(int steps) {
if (steps >= 1000) {
return '${(steps / 1000).toStringAsFixed(steps % 1000 == 0 ? 0 : 1)}k';
}
return steps.toString();
}
格式化的逻辑:
比如8500步显示成"8.5k",10000步显示成"10k"。steps % 1000 == 0 这个判断是为了避免显示"10.0k"这种多余的小数点。
这样的设计让大数字更容易理解,用户不用数有多少个0。
进度条
进度条用 Stack 实现,底层是灰色背景,上层是渐变色的进度:
const SizedBox(height: 16),
Stack(
children: [
Container(
height: 12,
decoration: BoxDecoration(
color: colors.inputBackground,
borderRadius: BorderRadius.circular(6),
),
),
背景层的设计:
背景高度12像素,圆角6像素(正好是高度的一半,形成胶囊形状)。这样的设计看起来更现代,也更容易识别。
进度部分用 FractionallySizedBox 控制宽度:
FractionallySizedBox(
widthFactor: progress,
child: Container(
height: 12,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [AppColors.primaryLight, const Color(0xFF80CBC4)]
: [AppColors.primary, const Color(0xFF4DB6AC)],
),
borderRadius: BorderRadius.circular(6),
),
),
),
],
),
进度层的设计:
widthFactor 接受0到1的值,正好对应进度百分比。渐变色让进度条看起来更有层次感,深色模式和浅色模式用不同的色值。
从浅绿到深绿的渐变,视觉上更有动感。
进度条下方显示百分比:
const SizedBox(height: 8),
Text(
'${(progress * 100).toStringAsFixed(0)}% ${l10n.dailyGoal.toLowerCase()}',
style: TextStyle(fontSize: 12, color: colors.textSecondary),
),
百分比显示:
toStringAsFixed(0) 把百分比转成整数,比如 0.456 变成 "46%"。这样更简洁,用户也更容易理解。
快捷操作按钮
底部放一排按钮,方便用户快速添加步数:
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildQuickAddButton(context, provider, 1000, '+1k'),
_buildQuickAddButton(context, provider, 2000, '+2k'),
_buildQuickAddButton(context, provider, 5000, '+5k'),
_buildInputButton(context, provider),
],
),
按钮的布局:
三个快捷添加按钮(+1k、+2k、+5k)加一个编辑按钮。spaceEvenly 让按钮均匀分布。
这样的设计让用户可以快速添加常见的步数,不用每次都手动输入。
快捷添加按钮的实现:
Widget _buildQuickAddButton(BuildContext context, UserProvider provider, int steps, String label) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final primaryColor = isDark ? AppColors.primaryLight : AppColors.primary;
return GestureDetector(
onTap: () => provider.addSteps(steps),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: primaryColor.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: primaryColor.withOpacity(0.3)),
),
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: primaryColor,
),
),
),
);
}
快捷按钮的设计:
点击时调用 provider.addSteps(steps),Provider会自动通知所有监听者更新UI。按钮用半透明背景加边框的设计,看起来轻量但又有明确的可点击区域。
这样的设计让用户知道这是一个可交互的元素,但又不会显得太突兀。
编辑按钮
编辑按钮的样式稍有不同,用实心背景突出它的重要性:
Widget _buildInputButton(BuildContext context, UserProvider provider) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final l10n = context.l10n;
return GestureDetector(
onTap: () => _showInputDialog(context, provider),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isDark ? AppColors.primaryLight : AppColors.primary,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(Icons.edit, size: 16, color: isDark ? Colors.black : Colors.white),
const SizedBox(width: 4),
Text(
l10n.edit,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isDark ? Colors.black : Colors.white,
),
),
],
),
),
);
}
编辑按钮的设计:
深色模式下按钮背景是亮色,所以文字和图标要用深色;浅色模式反过来。这种对比能保证按钮在任何主题下都清晰可见。
用实心背景而不是半透明背景,让这个按钮看起来更重要,用户更容易注意到。
输入对话框
点击编辑按钮弹出对话框,让用户手动输入步数:
void _showInputDialog(BuildContext context, UserProvider provider) {
final l10n = context.l10n;
final controller = TextEditingController(
text: provider.todayRecord.steps.toString(),
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.steps),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: l10n.steps,
suffixText: l10n.steps.toLowerCase(),
),
),
对话框的设计:
TextEditingController 的初始值设成当前步数,用户可以在此基础上修改。keyboardType: TextInputType.number 弹出数字键盘,方便输入。
suffixText 显示单位,让用户知道输入的是什么。
对话框的操作按钮:
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
final steps = int.tryParse(controller.text) ?? 0;
provider.updateTodayRecord(steps: steps);
Navigator.pop(context);
},
child: Text(l10n.save),
),
],
),
);
}
对话框操作的逻辑:
int.tryParse 安全地解析用户输入,如果输入的不是有效数字就返回null,这时用0作为默认值。保存后调用 provider.updateTodayRecord 更新数据,然后关闭对话框。
这样的设计防止了用户输入非法数据导致应用崩溃。
Provider中的方法
步数卡片用到了 UserProvider 中的两个方法:
// 添加步数
Future<void> addSteps(int steps) async {
_todayRecord = _todayRecord.copyWith(
steps: _todayRecord.steps + steps,
);
await _saveTodayRecord();
notifyListeners();
}
// 更新今日记录
Future<void> updateTodayRecord({
int? steps,
int? caloriesBurned,
int? waterCups,
double? weight,
}) async {
_todayRecord = _todayRecord.copyWith(
steps: steps,
caloriesBurned: caloriesBurned,
waterCups: waterCups,
weight: weight,
);
await _saveTodayRecord();
notifyListeners();
}
Provider方法的设计:
addSteps 是一个便捷方法,用于快速添加步数。updateTodayRecord 是一个通用方法,可以更新任何字段。
两个方法都会调用 _saveTodayRecord() 把数据持久化到本地存储,然后调用 notifyListeners() 通知UI更新。
关于卡路里计算
步数转卡路里的公式是一个简化的估算:
final calories = (steps * 0.04).round();
计算公式的说明:
这个系数0.04是基于平均体重(约70kg)和平均步速(约1.4m/s)的估算。实际上卡路里消耗取决于多个因素:
- 体重 – 体重越重,消耗的卡路里越多
- 步速 – 走得越快,消耗的卡路里越多
- 地形 – 上坡比平地消耗更多卡路里
- 年龄和性别 – 不同人群的代谢率不同
如果要更准确,可以根据用户的体重调整系数。比如用户体重是60kg,可以用 steps * 0.034 这样的系数。
交互体验的优化
步数卡片的交互设计有几个细节值得注意:
第一,快捷按钮的选择
+1k、+2k、+5k 这三个数字是精心选择的。1k是最小的快捷增量,2k是中等增量,5k是大增量。这样可以覆盖大多数用户的需求。
第二,编辑按钮的突出
编辑按钮用实心背景,和快捷按钮的半透明背景形成对比。这样用户知道这是一个更重要的操作。
第三,进度条的渐变色
渐变色让进度条看起来更有动感,也更容易吸引用户的注意力。
第四,步数的格式化
把大数字转成"k"的形式,更容易理解。比如"8.5k"比"8500"更直观。
可能的改进方向
当前实现已经能满足基本需求,但还有一些可以改进的地方:
第一,支持自定义目标
不同人的运动能力不同,可以让用户在设置里调整每日目标。
第二,支持多种快捷按钮
可以让用户自定义快捷按钮的数值,比如有人想要+500、+1000、+3000这样的组合。
第三,添加动画效果
点击快捷按钮时可以有一个数字上升的动画,更有趣味性。
第四,显示历史数据
可以显示过去几天的步数记录,让用户看到自己的运动趋势。
第五,添加提醒功能
如果用户一整天都没有走够步数,可以在晚上提醒用户。
性能考虑
在实现步数卡片时,有几个性能方面的考虑:
第一,避免频繁的Provider更新
每次点击都会调用 notifyListeners(),这会导致整个卡片重建。如果用户频繁点击,可能会有性能问题。
解决方案是使用 Selector 而不是 Consumer,只监听需要的数据。
第二,数据持久化的性能
每次更新都要写入 SharedPreferences,这是一个IO操作,可能比较耗时。
解决方案是使用数据库(如SQLite)代替 SharedPreferences,或者使用异步操作不阻塞UI。
第三,格式化方法的调用
_formatSteps 方法在build时会被调用多次。可以考虑缓存结果,避免重复计算。
小结
这篇文章我们实现了一个功能完整的步数卡片组件。主要涉及:
- Consumer监听 – 监听Provider数据变化
- FractionallySizedBox – 实现进度条效果
- Stack叠加 – 组合多个UI元素
- 深色模式适配 – 根据主题调整颜色
- 对话框输入 – 让用户手动输入数据
- 快捷操作 – 提供常用的快速操作
步数卡片的交互设计比较完整,用户可以通过快捷按钮快速添加,也可以通过对话框精确输入。
下一篇我们来实现身体数据卡片,展示体重、BMI等信息,还会涉及到更复杂的数据可视化。敬请期待!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net







