Flutter for OpenHarmony 健康管理App应用实战 - 饮水卡片实现
喝水是健康生活的重要组成部分,很多人忙起来就忘了喝水。我们的健康管理App提供了一个饮水追踪功能,用户可以记录每天喝了多少杯水,卡片会显示进度和总量。饮水卡片的交互比较有意思:用户可以点击杯子图标来记录喝水,也可以用加减按钮快速调整。这篇文章我们来实现这个功能。每日目标定义为静态常量,方便后续修改。如果将来要支持用户自定义目标,可以把这个值改成从Provider获取。这样的设计让代码更灵活,不用到

写在前面
喝水是健康生活的重要组成部分,很多人忙起来就忘了喝水。我们的健康管理App提供了一个饮水追踪功能,用户可以记录每天喝了多少杯水,卡片会显示进度和总量。
饮水卡片的交互比较有意思:用户可以点击杯子图标来记录喝水,也可以用加减按钮快速调整。这篇文章我们来实现这个功能。
设计分析
饮水卡片的布局从上到下依次是:
- 标题栏 - 显示"饮水"和当前进度(如"3 / 8 杯")
- 杯子图标行 - 8个杯子图标,已喝的显示填充状态
- 进度条 - 显示完成百分比
- 快捷按钮 - 加减按钮和当前毫升数
每日目标是8杯水,每杯250毫升,总共2000毫升。这是一个比较通用的标准,当然实际需求因人而异。
导入依赖
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../utils/colors.dart';
import '../l10n/app_localizations.dart';
import '../providers/user_provider.dart';
关于这些导入的说明:
饮水数据存储在 UserProvider 中,因为它属于用户的每日记录,和食物记录是分开的。这样的设计让数据管理更清晰,不同类型的数据由不同的Provider管理。
定义常量
class WaterCard extends StatelessWidget {
const WaterCard({super.key});
static const int dailyGoal = 8;
常量定义的好处:
每日目标定义为静态常量,方便后续修改。如果将来要支持用户自定义目标,可以把这个值改成从Provider获取。这样的设计让代码更灵活,不用到处修改硬编码的数字。
获取主题和数据
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;
return Consumer<UserProvider>(
builder: (context, provider, _) {
final cups = provider.todayRecord.waterCups;
final progress = (cups / dailyGoal).clamp(0.0, 1.0);
数据获取和计算:
从Provider获取今日已喝的杯数,计算进度百分比。clamp(0.0, 1.0) 确保进度值在0到1之间,防止超过100%。
primaryColor 根据深色模式选择不同的颜色,后面会多次用到,所以提前定义好。这样可以避免重复的三元表达式判断。
卡片容器
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colors.cardBackground,
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
容器的设计:
和其他卡片一样,20像素内边距,20像素圆角。这样的设计保持了整个App的视觉一致性。
标题栏
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.water,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: primaryColor,
),
),
Text(
'$cups / $dailyGoal ${l10n.cups}',
style: TextStyle(fontSize: 14, color: colors.textSecondary),
),
],
),
标题栏的布局:
左边是"饮水"标题,右边显示当前进度,如"3 / 8 杯"。这样用户一眼就能看到今天的饮水进度。
标题用主题色,进度用次要文字色,形成视觉层级。
杯子图标行
这是饮水卡片最有特色的部分:
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(dailyGoal, (index) {
final isFilled = index < cups;
return GestureDetector(
onTap: () => provider.updateTodayRecord(waterCups: index + 1),
child: Container(
width: 32,
height: 48,
decoration: BoxDecoration(
color: isFilled
? primaryColor.withOpacity(0.2)
: colors.inputBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isFilled ? primaryColor : colors.divider,
width: 2,
),
),
杯子图标的交互设计:
用 List.generate 生成8个杯子图标。isFilled 判断这个杯子是否已经喝过(索引小于已喝杯数)。
点击杯子时,直接把饮水量设置为这个杯子的序号加1。比如点击第3个杯子,就设置为3杯。这样用户可以快速调整,不用一杯一杯地加。这是一个很聪明的交互设计。
杯子的样式根据是否填充而不同:填充的杯子有主题色背景和边框,未填充的杯子是灰色背景和边框。
杯子内部的水和图标
child: Stack(
alignment: Alignment.bottomCenter,
children: [
if (isFilled)
Container(
width: double.infinity,
height: 40,
decoration: BoxDecoration(
color: primaryColor,
borderRadius: BorderRadius.circular(5),
),
),
Icon(
Icons.water_drop,
size: 20,
color: isFilled ? Colors.white : colors.textSecondary,
),
],
),
杯子内部的视觉设计:
用 Stack 叠加两层:底层是填充的水(一个主题色的矩形),上层是水滴图标。
填充的杯子,水滴图标是白色的,和蓝色背景形成对比;未填充的杯子,水滴图标是灰色的。这样的设计让用户能够清晰地看出哪些杯子已经喝过。
进度条
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: colors.inputBackground,
valueColor: AlwaysStoppedAnimation<Color>(
cups >= dailyGoal ? primaryColor : primaryColor.withOpacity(0.7),
),
minHeight: 8,
),
),
进度条的设计细节:
用Flutter内置的 LinearProgressIndicator 显示进度。ClipRRect 给进度条加上圆角,看起来更柔和。
如果已经完成目标(喝满8杯),进度条用完整的主题色;否则用70%透明度的主题色,视觉上稍微淡一点。这样可以暗示用户还没有完成目标。
进度条的高度设置为8像素,不会太显眼,但足够清晰。
快捷按钮区域
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildQuickButton(
context,
Icons.remove,
() {
if (cups > 0) {
provider.updateTodayRecord(waterCups: cups - 1);
}
},
),
const SizedBox(width: 16),
Text(
'${cups * 250} ml',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colors.textPrimary,
),
),
const SizedBox(width: 16),
_buildQuickButton(
context,
Icons.add,
() => provider.addWater(),
),
],
),
快捷按钮的交互逻辑:
中间显示当前的毫升数(杯数乘以250),两边是加减按钮。
减按钮有个判断:如果已经是0杯了,就不能再减了。这是一个边界情况处理,防止用户把饮水量设置成负数。
加按钮调用 provider.addWater(),这个方法会把杯数加1。这样用户可以快速增加饮水量,不用每次都点杯子图标。
快捷按钮组件
Widget _buildQuickButton(BuildContext context, IconData icon, VoidCallback onTap) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: onTap,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDark ? AppColors.primaryLight : AppColors.primary,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: isDark ? Colors.black : Colors.white, size: 24),
),
);
}
按钮组件的设计:
按钮是一个40x40的圆角方块,背景是主题色,图标是白色(深色模式下是黑色)。
这样的设计让按钮看起来像一个可点击的元素,用户能够直观地理解它的功能。
Provider中的方法
饮水卡片用到了 UserProvider 中的两个方法:
// 更新今日记录
Future<void> updateTodayRecord({
int? caloriesConsumed,
int? caloriesBurned,
int? steps,
int? waterCups,
double? weight,
}) async {
_todayRecord = _todayRecord.copyWith(
caloriesConsumed: caloriesConsumed,
caloriesBurned: caloriesBurned,
steps: steps,
waterCups: waterCups,
weight: weight,
);
await _saveTodayRecord();
notifyListeners();
}
updateTodayRecord方法的设计:
这是一个通用方法,可以更新今日记录的任何字段。使用可选参数,只更新传入的字段,其他字段保持不变。
copyWith 是一个常见的Dart模式,用来创建一个新对象,只改变指定的字段。这样可以保证数据的不可变性。
添加饮水的便捷方法
// 添加饮水
Future<void> addWater() async {
_todayRecord = _todayRecord.copyWith(
waterCups: _todayRecord.waterCups + 1,
);
await _saveTodayRecord();
notifyListeners();
}
便捷方法的好处:
addWater 是一个便捷方法,专门用于增加饮水量。相比调用 updateTodayRecord(waterCups: cups + 1),这个方法更简洁,也更容易理解。
两个方法都会调用 _saveTodayRecord() 把数据持久化到本地存储,然后调用 notifyListeners() 通知UI更新。
数据持久化
饮水数据存储在 SharedPreferences 中:
Future<void> _saveTodayRecord() async {
final prefs = await SharedPreferences.getInstance();
final recordsJson = prefs.getString(_recordsKey);
List<dynamic> records = [];
if (recordsJson != null) {
records = jsonDecode(recordsJson);
// 移除今天的旧记录
records.removeWhere((r) {
final date = DateTime.tryParse(r['date'] ?? '');
return date != null && _isSameDay(date, DateTime.now());
});
}
// 添加新记录
records.add(_todayRecord.toJson());
// 只保留最近30天的记录
final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30));
records.removeWhere((r) {
final date = DateTime.tryParse(r['date'] ?? '');
return date != null && date.isBefore(thirtyDaysAgo);
});
await prefs.setString(_recordsKey, jsonEncode(records));
}
数据持久化的逻辑:
每次保存时,先移除今天的旧记录,再添加新记录。这样可以确保每天只有一条记录。
同时清理30天前的旧数据,避免存储空间无限增长。这是一个很重要的优化,特别是对于长期使用的应用。
为什么用List.generate
在杯子图标行中,我们用 List.generate 而不是手动写8个杯子。这样做有几个好处:
第一,代码简洁
// 好的做法
List.generate(dailyGoal, (index) { ... })
// 不好的做法
[
_buildCup(0),
_buildCup(1),
_buildCup(2),
// ... 重复6次
]
第二,易于修改
如果要改成10杯,只需要改 dailyGoal 的值,不用修改UI代码。
第三,性能更好
List.generate 是懒加载的,只在需要时创建元素。
交互体验优化
饮水卡片的交互设计有几个细节值得注意:
第一,点击杯子可以直接设置杯数
而不是只能加1。这样用户如果漏记了几杯,可以快速补上。比如用户想设置为5杯,直接点第5个杯子就行,不用点5次加按钮。
第二,杯子图标有视觉反馈
填充和未填充状态明显不同。用户一眼就能看出喝了多少。
第三,显示毫升数而不只是杯数
有些用户更习惯用毫升来衡量饮水量,这样更直观。而且250毫升这个单位也是标准的杯子容量。
第四,进度条提供了另一种视角
让用户知道距离目标还有多远。有些用户喜欢看进度条,有些喜欢看杯子图标,两种方式都提供了。
边界情况处理
在实现饮水卡片时,我们处理了几个边界情况:
第一,减按钮的边界检查
if (cups > 0) {
provider.updateTodayRecord(waterCups: cups - 1);
}
防止用户把饮水量设置成负数。
第二,进度百分比的clamp
final progress = (cups / dailyGoal).clamp(0.0, 1.0);
防止进度超过100%。
第三,无数据时的默认值
如果用户还没有记录任何饮水,显示0杯,这是合理的默认值。
可能的改进方向
当前实现已经能满足基本需求,但还有一些可以改进的地方:
第一,支持自定义目标
不同人的饮水需求不同,可以让用户在设置里调整每日目标。这样可以让应用更个性化。
第二,支持自定义杯子容量
有些人用的杯子是300毫升或500毫升,可以让用户设置。这样计算毫升数时会更准确。
第三,添加提醒功能
定时提醒用户喝水,这个功能我们会在后面的文章中实现。这是一个很重要的功能,可以帮助用户养成喝水的习惯。
第四,添加动画效果
点击杯子时可以有一个水位上升的动画,更有趣味性。这样可以增加用户的参与感。
第五,显示历史数据
可以显示过去几天的饮水记录,让用户看到自己的饮水趋势。这样可以帮助用户更好地了解自己的饮水习惯。
性能考虑
在实现饮水卡片时,有几个性能方面的考虑:
第一,避免频繁的Provider更新
每次点击都会调用 notifyListeners(),这会导致整个卡片重建。如果用户频繁点击,可能会有性能问题。
解决方案是使用 Selector 而不是 Consumer,只监听需要的数据。
第二,数据持久化的性能
每次更新都要写入 SharedPreferences,这是一个IO操作,可能比较耗时。
解决方案是使用数据库(如SQLite)代替 SharedPreferences,或者使用异步操作不阻塞UI。
第三,List.generate的性能
虽然 List.generate 很方便,但如果杯子数量很多(比如100杯),可能会有性能问题。
解决方案是使用 ListView.builder 代替 List.generate,这样可以实现虚拟滚动。
小结
这篇文章我们实现了饮水卡片,主要涉及:
- List.generate - 动态生成杯子图标
- Stack - 叠加水位和图标
- LinearProgressIndicator - 显示进度
- Provider数据绑定 - 管理饮水数据
- SharedPreferences - 数据持久化
饮水卡片的交互设计比较有特色,用户可以通过点击杯子或加减按钮来记录饮水量,操作简单直观。
下一篇我们来实现步数卡片,它会用到渐变色的进度条和快捷添加按钮。敬请期待!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐

所有评论(0)