请添加图片描述

写在前面

喝水是健康生活的重要组成部分,很多人忙起来就忘了喝水。我们的健康管理App提供了一个饮水追踪功能,用户可以记录每天喝了多少杯水,卡片会显示进度和总量。

饮水卡片的交互比较有意思:用户可以点击杯子图标来记录喝水,也可以用加减按钮快速调整。这篇文章我们来实现这个功能。


设计分析

饮水卡片的布局从上到下依次是:

  1. 标题栏 - 显示"饮水"和当前进度(如"3 / 8 杯")
  2. 杯子图标行 - 8个杯子图标,已喝的显示填充状态
  3. 进度条 - 显示完成百分比
  4. 快捷按钮 - 加减按钮和当前毫升数

每日目标是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

Logo

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

更多推荐