Flutter健身追踪应用开发教程

项目简介

健身追踪是一款专业的运动记录和卡路里管理应用,帮助用户科学记录运动数据、追踪健身进度。本项目使用Flutter实现了完整的运动记录、数据统计、目标管理等功能,让健身变得更加有趣和高效。

运行效果图
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

核心特性

  • 卡路里追踪:实时显示今日消耗和目标进度
  • 运动记录:支持10种常见运动类型
  • 智能计算:根据运动类型和时长自动计算卡路里
  • 数据统计:7天消耗趋势图表展示
  • 目标管理:自定义每日卡路里目标
  • 个人设置:体重管理和目标设置
  • 数据持久化:本地存储所有运动记录
  • 进度可视化:直观的进度条和图表
  • 快速添加:简洁的运动添加流程
  • 历史记录:完整的运动历史查看

技术架构

数据模型设计

运动类型模型
class Exercise {
  final String id;                    // 唯一标识
  final String name;                  // 运动名称
  final String category;              // 运动分类
  final double caloriesPerMinute;     // 每分钟消耗卡路里
  final String icon;                  // 图标
}

字段说明

  • id:运动类型唯一标识符
  • name:运动名称(如跑步、游泳)
  • category:运动分类(有氧运动、力量训练等)
  • caloriesPerMinute:每分钟消耗的卡路里数
  • icon:运动图标emoji

卡路里消耗参考值

运动类型 每分钟消耗 分类
跳绳 12.0 千卡 有氧运动
游泳 11.0 千卡 有氧运动
跑步 10.0 千卡 有氧运动
足球 9.5 千卡 球类运动
篮球 9.0 千卡 球类运动
爬山 8.5 千卡 户外运动
骑行 8.0 千卡 有氧运动
羽毛球 7.0 千卡 球类运动
力量训练 6.0 千卡 力量
瑜伽 3.0 千卡 柔韧性
运动记录模型
class WorkoutRecord {
  final String id;                // 记录ID
  final String exerciseId;        // 运动类型ID
  final String exerciseName;      // 运动名称
  final int duration;             // 时长(分钟)
  final double calories;          // 消耗卡路里
  final DateTime date;            // 记录时间
  final String? notes;            // 备注(可选)
}

字段说明

  • id:记录唯一标识(使用时间戳)
  • exerciseId:关联的运动类型ID
  • exerciseName:运动名称(冗余存储,便于显示)
  • duration:运动时长(分钟)
  • calories:消耗的卡路里数
  • date:记录创建时间
  • notes:用户备注信息

卡路里计算公式

消耗卡路里 = 运动类型每分钟消耗 × 运动时长

状态管理

class _FitnessHomePageState extends State<FitnessHomePage> {
  int _selectedIndex = 0;              // 当前选中的底部导航索引
  List<WorkoutRecord> records = [];    // 所有运动记录
  double dailyCalorieGoal = 500;       // 每日卡路里目标
  double weight = 70;                  // 用户体重
}

状态变量说明

  • _selectedIndex:底部导航栏当前页面索引(0-3)
  • records:所有运动记录列表
  • dailyCalorieGoal:用户设定的每日卡路里消耗目标
  • weight:用户体重(用于未来扩展BMI计算等)

核心功能实现

1. 数据持久化

Future<void> _loadData() async {
  final prefs = await SharedPreferences.getInstance();
  
  // 加载运动记录
  final recordsData = prefs.getStringList('workout_records') ?? [];
  setState(() {
    records = recordsData
        .map((json) => WorkoutRecord.fromJson(jsonDecode(json)))
        .toList();
    
    // 加载用户设置
    dailyCalorieGoal = prefs.getDouble('daily_calorie_goal') ?? 500;
    weight = prefs.getDouble('user_weight') ?? 70;
  });
}

Future<void> _saveRecords() async {
  final prefs = await SharedPreferences.getInstance();
  final recordsData = records.map((r) => jsonEncode(r.toJson())).toList();
  await prefs.setStringList('workout_records', recordsData);
}

Future<void> _saveSettings() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setDouble('daily_calorie_goal', dailyCalorieGoal);
  await prefs.setDouble('user_weight', weight);
}

存储策略

  • 使用SharedPreferences进行本地存储
  • 运动记录序列化为JSON字符串列表
  • 用户设置单独存储为double类型
  • 应用启动时自动加载数据
  • 数据变更时立即保存

存储键值

  • workout_records:运动记录列表
  • daily_calorie_goal:每日目标
  • user_weight:用户体重

2. 今日数据统计

final today = DateTime.now();
final todayRecords = records.where((r) {
  return r.date.year == today.year &&
      r.date.month == today.month &&
      r.date.day == today.day;
}).toList();

final todayCalories = todayRecords.fold<double>(
  0,
  (sum, record) => sum + record.calories,
);

final progress = (todayCalories / dailyCalorieGoal).clamp(0.0, 1.0);

统计逻辑

  1. 筛选今日记录

    • 比较年、月、日是否相同
    • 使用where方法过滤
  2. 计算总消耗

    • 使用fold累加所有记录的卡路里
    • 初始值为0
  3. 计算进度

    • 进度 = 今日消耗 / 目标
    • 使用clamp限制在0-1之间

时间复杂度:O(n),n为记录总数

3. 7天数据统计

final last7Days = List.generate(7, (index) {
  return DateTime.now().subtract(Duration(days: 6 - index));
});

final dailyCalories = last7Days.map((date) {
  final dayRecords = records.where((r) {
    return r.date.year == date.year &&
        r.date.month == date.month &&
        r.date.day == date.day;
  });
  return dayRecords.fold<double>(0, (sum, r) => sum + r.calories);
}).toList();

统计流程

  1. 生成最近7天的日期列表
  2. 对每一天筛选对应的记录
  3. 计算每天的总消耗
  4. 返回7个数值的列表

日期生成

  • 从6天前到今天
  • 使用subtract方法倒推日期

4. 添加运动记录

Future<void> _showAddWorkoutDialog() async {
  Exercise? selectedExercise;
  final durationController = TextEditingController();
  
  final result = await showDialog<WorkoutRecord>(
    context: context,
    builder: (context) => StatefulBuilder(
      builder: (context, setState) => AlertDialog(
        title: const Text('添加运动'),
        content: Column(
          children: [
            // 运动类型下拉选择
            DropdownButtonFormField<Exercise>(
              value: selectedExercise,
              items: _getExercises().map((exercise) {
                return DropdownMenuItem(
                  value: exercise,
                  child: Text(exercise.name),
                );
              }).toList(),
              onChanged: (value) {
                setState(() {
                  selectedExercise = value;
                });
              },
            ),
            // 时长输入
            TextField(
              controller: durationController,
              keyboardType: TextInputType.number,
              decoration: const InputDecoration(
                labelText: '时长 (分钟)',
              ),
            ),
            // 预计消耗显示
            if (selectedExercise != null && durationController.text.isNotEmpty)
              Text(
                '预计消耗: ${(selectedExercise!.caloriesPerMinute * 
                    (int.tryParse(durationController.text) ?? 0)).toInt()} 千卡',
              ),
          ],
        ),
      ),
    ),
  );
  
  if (result != null) {
    _addRecord(result);
  }
}

对话框特性

  • 使用StatefulBuilder实现对话框内状态更新
  • 下拉选择运动类型
  • 数字键盘输入时长
  • 实时显示预计消耗
  • 返回WorkoutRecord对象

数据验证

  • 运动类型不能为空
  • 时长必须为正整数
  • 自动计算卡路里

5. 日期格式化

String _formatDate(DateTime date) {
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  final yesterday = today.subtract(const Duration(days: 1));
  final dateOnly = DateTime(date.year, date.month, date.day);

  if (dateOnly == today) {
    return '今天 ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
  } else if (dateOnly == yesterday) {
    return '昨天 ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
  } else {
    return '${date.month}${date.day}日 ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
  }
}

格式化规则

  • 今天:显示"今天 HH:MM"
  • 昨天:显示"昨天 HH:MM"
  • 其他:显示"M月D日 HH:MM"
  • 时间补零对齐

UI组件设计

1. 卡路里卡片

Widget _buildCalorieCard(double todayCalories, double progress) {
  return Card(
    elevation: 4,
    child: Container(
      padding: const EdgeInsets.all(24),
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: [Colors.green.shade400, Colors.green.shade600],
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        children: [
          const Text('今日消耗', style: TextStyle(color: Colors.white70)),
          Text(
            '${todayCalories.toInt()}',
            style: const TextStyle(
              fontSize: 48,
              fontWeight: FontWeight.bold,
              color: Colors.white,
            ),
          ),
          Text('/ ${dailyCalorieGoal.toInt()} 千卡'),
          LinearProgressIndicator(
            value: progress,
            minHeight: 10,
            backgroundColor: Colors.white30,
            valueColor: const AlwaysStoppedAnimation<Color>(Colors.white),
          ),
          Text('${(progress * 100).toInt()}% 完成'),
        ],
      ),
    ),
  );
}

设计特点

  • 渐变绿色背景
  • 大字号显示消耗数值
  • 进度条可视化
  • 百分比显示
  • 圆角卡片阴影

视觉层次

  1. 标题(今日消耗)
  2. 主数值(48号字体)
  3. 目标对比
  4. 进度条
  5. 完成百分比

2. 运动记录卡片

Widget _buildWorkoutCard(WorkoutRecord record) {
  return Card(
    margin: const EdgeInsets.only(bottom: 12),
    child: ListTile(
      leading: CircleAvatar(
        backgroundColor: Colors.green.shade100,
        child: Icon(Icons.fitness_center, color: Colors.green.shade700),
      ),
      title: Text(
        record.exerciseName,
        style: const TextStyle(fontWeight: FontWeight.bold),
      ),
      subtitle: Text('${record.duration}分钟 · ${record.calories.toInt()}千卡'),
      trailing: IconButton(
        icon: const Icon(Icons.delete_outline),
        onPressed: () => _deleteRecord(record.id),
      ),
    ),
  );
}

卡片结构

  • 左侧:圆形图标
  • 中间:运动名称 + 时长和卡路里
  • 右侧:删除按钮

交互功能

  • 点击删除按钮移除记录
  • 卡片间距12像素

3. 统计卡片

Widget _buildStatCard(String label, String value, String unit, IconData icon) {
  return Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        children: [
          Icon(icon, size: 32, color: Colors.green.shade600),
          const SizedBox(height: 8),
          Text(
            value,
            style: const TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.bold,
            ),
          ),
          Text(unit, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
          const SizedBox(height: 4),
          Text(label, style: TextStyle(fontSize: 14, color: Colors.grey.shade700)),
        ],
      ),
    ),
  );
}

统计卡片布局

┌─────────────┐
│    图标     │
│   数值      │
│   单位      │
│   标签      │
└─────────────┘

使用场景

  • 总消耗
  • 总时长
  • 平均消耗
  • 运动次数

4. 7天趋势图表

Widget _buildChart(List<DateTime> dates, List<double> calories) {
  final maxCalories = calories.isEmpty ? 100.0 : 
      calories.reduce((a, b) => a > b ? a : b);
  
  return Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: SizedBox(
        height: 200,
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.end,
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: List.generate(7, (index) {
            final height = maxCalories > 0 ? 
                (calories[index] / maxCalories * 150) : 0.0;
            return Column(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                if (calories[index] > 0)
                  Text('${calories[index].toInt()}'),
                Container(
                  width: 30,
                  height: height.clamp(10.0, 150.0),
                  decoration: BoxDecoration(
                    color: Colors.green.shade400,
                    borderRadius: BorderRadius.circular(4),
                  ),
                ),
                Text(_getWeekday(dates[index])),
              ],
            );
          }),
        ),
      ),
    ),
  );
}

图表特性

  • 柱状图展示
  • 自动缩放(基于最大值)
  • 显示具体数值
  • 星期标签
  • 最小高度10像素

缩放算法

柱高 = (当日消耗 / 最大消耗) × 150

5. 底部导航栏

NavigationBar(
  selectedIndex: _selectedIndex,
  onDestinationSelected: (index) {
    setState(() {
      _selectedIndex = index;
    });
  },
  destinations: const [
    NavigationDestination(
      icon: Icon(Icons.home_outlined),
      selectedIcon: Icon(Icons.home),
      label: '首页',
    ),
    NavigationDestination(
      icon: Icon(Icons.list_outlined),
      selectedIcon: Icon(Icons.list),
      label: '记录',
    ),
    NavigationDestination(
      icon: Icon(Icons.bar_chart_outlined),
      selectedIcon: Icon(Icons.bar_chart),
      label: '统计',
    ),
    NavigationDestination(
      icon: Icon(Icons.settings_outlined),
      selectedIcon: Icon(Icons.settings),
      label: '设置',
    ),
  ],
)

导航特性

  • Material 3设计风格
  • 4个导航项
  • 选中和未选中图标
  • 自动高亮当前页

页面对应

  • 0:首页(今日运动)
  • 1:记录(历史记录)
  • 2:统计(数据分析)
  • 3:设置(个人设置)

6. 设置页面

Widget _buildSettingsPage() {
  return Scaffold(
    appBar: AppBar(title: const Text('设置')),
    body: ListView(
      children: [
        Card(
          child: ListTile(
            leading: const Icon(Icons.monitor_weight),
            title: const Text('体重'),
            subtitle: Text('$weight kg'),
            trailing: const Icon(Icons.edit),
            onTap: () => _showWeightDialog(),
          ),
        ),
        Card(
          child: ListTile(
            leading: const Icon(Icons.local_fire_department),
            title: const Text('每日卡路里目标'),
            subtitle: Text('${dailyCalorieGoal.toInt()} 千卡'),
            trailing: const Icon(Icons.edit),
            onTap: () => _showCalorieGoalDialog(),
          ),
        ),
        Card(
          child: ListTile(
            leading: const Icon(Icons.delete_outline),
            title: const Text('清除所有数据'),
            textColor: Colors.red,
            onTap: () => _showClearDataDialog(),
          ),
        ),
      ],
    ),
  );
}

设置项

  1. 体重设置

    • 显示当前体重
    • 点击弹出输入对话框
    • 数字键盘输入
  2. 目标设置

    • 显示当前目标
    • 点击修改目标值
    • 立即保存
  3. 数据清除

    • 红色警告样式
    • 二次确认对话框
    • 清除所有记录

功能扩展建议

1. GPS运动追踪

import 'package:geolocator/geolocator.dart';

class GPSTracker {
  List<Position> positions = [];
  double totalDistance = 0;
  
  Future<void> startTracking() async {
    final permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) return;
    
    Geolocator.getPositionStream(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 10,
      ),
    ).listen((Position position) {
      if (positions.isNotEmpty) {
        final lastPos = positions.last;
        final distance = Geolocator.distanceBetween(
          lastPos.latitude,
          lastPos.longitude,
          position.latitude,
          position.longitude,
        );
        totalDistance += distance;
      }
      positions.add(position);
    });
  }
  
  double getAverageSpeed() {
    if (positions.length < 2) return 0;
    final duration = positions.last.timestamp
        .difference(positions.first.timestamp)
        .inSeconds;
    return totalDistance / duration; // 米/秒
  }
}

GPS功能

  • 实时位置追踪
  • 距离计算
  • 速度统计
  • 运动轨迹记录

2. 心率监测

import 'package:heart_rate_monitor/heart_rate_monitor.dart';

class HeartRateMonitor {
  int currentHeartRate = 0;
  List<int> heartRateHistory = [];
  
  Future<void> startMonitoring() async {
    HeartRateMonitor.start().listen((heartRate) {
      currentHeartRate = heartRate;
      heartRateHistory.add(heartRate);
    });
  }
  
  int getAverageHeartRate() {
    if (heartRateHistory.isEmpty) return 0;
    return heartRateHistory.reduce((a, b) => a + b) ~/ 
        heartRateHistory.length;
  }
  
  String getHeartRateZone() {
    if (currentHeartRate < 100) return '热身区';
    if (currentHeartRate < 120) return '燃脂区';
    if (currentHeartRate < 140) return '有氧区';
    if (currentHeartRate < 160) return '无氧区';
    return '极限区';
  }
}

心率功能

  • 实时心率监测
  • 心率区间判断
  • 平均心率计算
  • 历史数据记录

3. 运动计划

class WorkoutPlan {
  final String id;
  final String name;
  final List<PlannedExercise> exercises;
  final int daysPerWeek;
  final DateTime startDate;
  
  WorkoutPlan({
    required this.id,
    required this.name,
    required this.exercises,
    required this.daysPerWeek,
    required this.startDate,
  });
}

class PlannedExercise {
  final String exerciseId;
  final int targetDuration;
  final int targetCalories;
  final String dayOfWeek;
  
  PlannedExercise({
    required this.exerciseId,
    required this.targetDuration,
    required this.targetCalories,
    required this.dayOfWeek,
  });
}

class WorkoutPlanWidget extends StatelessWidget {
  final WorkoutPlan plan;
  
  
  Widget build(BuildContext context) {
    return Card(
      child: ExpansionTile(
        title: Text(plan.name),
        subtitle: Text('每周${plan.daysPerWeek}天'),
        children: plan.exercises.map((exercise) {
          return ListTile(
            title: Text(exercise.dayOfWeek),
            subtitle: Text(
              '${exercise.targetDuration}分钟 · 目标${exercise.targetCalories}千卡',
            ),
            trailing: Checkbox(
              value: false,
              onChanged: (value) {
                // 标记完成
              },
            ),
          );
        }).toList(),
      ),
    );
  }
}

计划功能

  • 自定义运动计划
  • 每周训练安排
  • 目标设定
  • 完成度追踪

4. 社交分享

import 'package:share_plus/share_plus.dart';

class WorkoutSharer {
  Future<void> shareWorkout(WorkoutRecord record) async {
    final text = '''
🏃 今日运动打卡
运动:${record.exerciseName}
时长:${record.duration}分钟
消耗:${record.calories.toInt()}千卡
日期:${_formatDate(record.date)}

#健身打卡 #运动记录
    ''';
    
    await Share.share(text);
  }
  
  Future<void> shareWeeklyStats(List<WorkoutRecord> weekRecords) async {
    final totalCalories = weekRecords.fold<double>(
      0, 
      (sum, r) => sum + r.calories,
    );
    final totalDuration = weekRecords.fold<int>(
      0, 
      (sum, r) => sum + r.duration,
    );
    
    final text = '''
📊 本周运动总结
运动次数:${weekRecords.length}次
总时长:$totalDuration分钟
总消耗:${totalCalories.toInt()}千卡

坚持就是胜利!💪
#健身周报 #运动打卡
    ''';
    
    await Share.share(text);
  }
}

分享功能

  • 单次运动分享
  • 周报分享
  • 文字格式化
  • 社交媒体集成

5. 数据导出

import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:csv/csv.dart';

class DataExporter {
  Future<String> exportToCSV(List<WorkoutRecord> records) async {
    final rows = [
      ['日期', '运动', '时长(分钟)', '卡路里', '备注'],
      ...records.map((r) => [
        r.date.toIso8601String(),
        r.exerciseName,
        r.duration.toString(),
        r.calories.toString(),
        r.notes ?? '',
      ]),
    ];
    
    final csv = const ListToCsvConverter().convert(rows);
    
    final directory = await getApplicationDocumentsDirectory();
    final file = File('${directory.path}/workout_records.csv');
    await file.writeAsString(csv);
    
    return file.path;
  }
  
  Future<String> exportToJSON(List<WorkoutRecord> records) async {
    final json = jsonEncode({
      'exportDate': DateTime.now().toIso8601String(),
      'totalRecords': records.length,
      'records': records.map((r) => r.toJson()).toList(),
    });
    
    final directory = await getApplicationDocumentsDirectory();
    final file = File('${directory.path}/workout_records.json');
    await file.writeAsString(json);
    
    return file.path;
  }
}

导出格式

  • CSV格式(Excel兼容)
  • JSON格式(数据交换)
  • 包含所有字段
  • 保存到文档目录

6. 成就系统

class Achievement {
  final String id;
  final String name;
  final String description;
  final String icon;
  final int targetValue;
  final String type; // 'calories', 'duration', 'count'
  
  Achievement({
    required this.id,
    required this.name,
    required this.description,
    required this.icon,
    required this.targetValue,
    required this.type,
  });
}

class AchievementManager {
  final List<Achievement> achievements = [
    Achievement(
      id: '1',
      name: '初出茅庐',
      description: '完成第1次运动',
      icon: '🌱',
      targetValue: 1,
      type: 'count',
    ),
    Achievement(
      id: '2',
      name: '燃脂达人',
      description: '累计消耗1000千卡',
      icon: '🔥',
      targetValue: 1000,
      type: 'calories',
    ),
    Achievement(
      id: '3',
      name: '时间管理大师',
      description: '累计运动100分钟',
      icon: '⏰',
      targetValue: 100,
      type: 'duration',
    ),
    Achievement(
      id: '4',
      name: '坚持不懈',
      description: '连续7天运动',
      icon: '💪',
      targetValue: 7,
      type: 'streak',
    ),
  ];
  
  List<String> checkAchievements(List<WorkoutRecord> records) {
    final unlocked = <String>[];
    
    for (final achievement in achievements) {
      if (achievement.type == 'count') {
        if (records.length >= achievement.targetValue) {
          unlocked.add(achievement.id);
        }
      } else if (achievement.type == 'calories') {
        final total = records.fold<double>(0, (sum, r) => sum + r.calories);
        if (total >= achievement.targetValue) {
          unlocked.add(achievement.id);
        }
      } else if (achievement.type == 'duration') {
        final total = records.fold<int>(0, (sum, r) => sum + r.duration);
        if (total >= achievement.targetValue) {
          unlocked.add(achievement.id);
        }
      }
    }
    
    return unlocked;
  }
}

成就类型

  • 运动次数成就
  • 卡路里成就
  • 时长成就
  • 连续打卡成就

7. 体重追踪

class WeightRecord {
  final DateTime date;
  final double weight;
  final double? bodyFat;
  final double? muscleMass;
  
  WeightRecord({
    required this.date,
    required this.weight,
    this.bodyFat,
    this.muscleMass,
  });
  
  double get bmi => weight / (1.75 * 1.75); // 假设身高1.75m
  
  String get bmiCategory {
    if (bmi < 18.5) return '偏瘦';
    if (bmi < 24) return '正常';
    if (bmi < 28) return '偏胖';
    return '肥胖';
  }
}

class WeightTracker extends StatelessWidget {
  final List<WeightRecord> records;
  
  
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 当前体重卡片
        Card(
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                Text(
                  '${records.last.weight} kg',
                  style: const TextStyle(
                    fontSize: 48,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                Text('BMI: ${records.last.bmi.toStringAsFixed(1)}'),
                Text(records.last.bmiCategory),
              ],
            ),
          ),
        ),
        // 体重趋势图
        _buildWeightChart(records),
      ],
    );
  }
  
  Widget _buildWeightChart(List<WeightRecord> records) {
    // 绘制体重变化曲线图
    return Container(
      height: 200,
      child: CustomPaint(
        painter: WeightChartPainter(records),
      ),
    );
  }
}

体重功能

  • 体重记录
  • BMI计算
  • 体脂率追踪
  • 趋势图表

8. 营养建议

class NutritionCalculator {
  final double weight;
  final String activityLevel;
  
  NutritionCalculator({
    required this.weight,
    required this.activityLevel,
  });
  
  double get bmr {
    // 基础代谢率(简化公式)
    return 10 * weight + 6.25 * 170 - 5 * 25 + 5;
  }
  
  double get tdee {
    // 总能量消耗
    final multipliers = {
      'sedentary': 1.2,
      'light': 1.375,
      'moderate': 1.55,
      'active': 1.725,
      'very_active': 1.9,
    };
    return bmr * (multipliers[activityLevel] ?? 1.2);
  }
  
  Map<String, double> getDailyNutrition() {
    return {
      'calories': tdee,
      'protein': weight * 1.6, // 克
      'carbs': tdee * 0.5 / 4, // 克
      'fat': tdee * 0.3 / 9, // 克
    };
  }
  
  String getRecommendation(double caloriesBurned) {
    final deficit = caloriesBurned;
    if (deficit > 500) {
      return '今天运动量很大,建议适当增加碳水摄入';
    } else if (deficit > 300) {
      return '运动量适中,保持当前饮食即可';
    } else {
      return '运动量较少,注意控制饮食';
    }
  }
}

营养功能

  • BMR计算
  • TDEE计算
  • 营养素建议
  • 饮食建议

性能优化

1. 数据分页加载

class PaginatedRecordsList extends StatefulWidget {
  
  State<PaginatedRecordsList> createState() => _PaginatedRecordsListState();
}

class _PaginatedRecordsListState extends State<PaginatedRecordsList> {
  final ScrollController _scrollController = ScrollController();
  List<WorkoutRecord> displayedRecords = [];
  int currentPage = 0;
  final int pageSize = 20;
  
  
  void initState() {
    super.initState();
    _loadPage();
    _scrollController.addListener(_onScroll);
  }
  
  void _loadPage() {
    final start = currentPage * pageSize;
    final end = (start + pageSize).clamp(0, allRecords.length);
    setState(() {
      displayedRecords.addAll(allRecords.sublist(start, end));
      currentPage++;
    });
  }
  
  void _onScroll() {
    if (_scrollController.position.pixels ==
        _scrollController.position.maxScrollExtent) {
      _loadPage();
    }
  }
}

优化效果

  • 减少初始加载时间
  • 降低内存占用
  • 滚动加载更多

2. 图表缓存

class CachedChart extends StatefulWidget {
  final List<double> data;
  
  
  State<CachedChart> createState() => _CachedChartState();
}

class _CachedChartState extends State<CachedChart> {
  ui.Image? cachedImage;
  
  Future<void> _generateChart() async {
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);
    
    // 绘制图表
    _drawChart(canvas, widget.data);
    
    final picture = recorder.endRecording();
    cachedImage = await picture.toImage(300, 200);
    setState(() {});
  }
  
  
  Widget build(BuildContext context) {
    if (cachedImage == null) {
      _generateChart();
      return CircularProgressIndicator();
    }
    
    return CustomPaint(
      painter: CachedImagePainter(cachedImage!),
    );
  }
}

缓存策略

  • 图表预渲染
  • 图片缓存
  • 减少重绘

3. 数据索引

class RecordIndex {
  final Map<String, List<WorkoutRecord>> dateIndex = {};
  final Map<String, List<WorkoutRecord>> exerciseIndex = {};
  
  void buildIndex(List<WorkoutRecord> records) {
    dateIndex.clear();
    exerciseIndex.clear();
    
    for (final record in records) {
      // 日期索引
      final dateKey = '${record.date.year}-${record.date.month}-${record.date.day}';
      dateIndex.putIfAbsent(dateKey, () => []).add(record);
      
      // 运动类型索引
      exerciseIndex.putIfAbsent(record.exerciseId, () => []).add(record);
    }
  }
  
  List<WorkoutRecord> getRecordsByDate(DateTime date) {
    final key = '${date.year}-${date.month}-${date.day}';
    return dateIndex[key] ?? [];
  }
  
  List<WorkoutRecord> getRecordsByExercise(String exerciseId) {
    return exerciseIndex[exerciseId] ?? [];
  }
}

索引优化

  • 日期索引快速查询
  • 运动类型索引
  • O(1)查询复杂度

项目结构

lib/
├── main.dart
├── models/
│   ├── exercise.dart
│   ├── workout_record.dart
│   ├── weight_record.dart
│   └── achievement.dart
├── screens/
│   ├── home_page.dart
│   ├── records_page.dart
│   ├── stats_page.dart
│   └── settings_page.dart
├── widgets/
│   ├── calorie_card.dart
│   ├── workout_card.dart
│   ├── stat_card.dart
│   └── chart_widget.dart
├── services/
│   ├── storage_service.dart
│   ├── gps_tracker.dart
│   └── data_exporter.dart
└── utils/
    ├── constants.dart
    ├── date_formatter.dart
    └── calorie_calculator.dart

依赖包

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.2.2      # 本地存储
  
  # 可选扩展
  geolocator: ^11.0.0             # GPS定位
  heart_rate_monitor: ^1.0.0      # 心率监测
  share_plus: ^7.2.2              # 社交分享
  path_provider: ^2.1.2           # 文件路径
  csv: ^6.0.0                     # CSV导出
  fl_chart: ^0.66.0               # 高级图表
  permission_handler: ^11.3.0     # 权限管理

数据流程图

添加运动

查看统计

修改设置

应用启动

加载本地数据

显示首页

用户操作

选择运动类型

输入时长

计算卡路里

保存记录

更新UI

计算统计数据

生成图表

显示统计页

更新设置

保存设置

状态管理流程

用户添加

用户删除

用户设置

初始化

加载数据

空闲状态

添加记录

计算卡路里

保存数据

更新UI

删除记录

修改设置

保存设置

卡路里计算公式

基础公式

消耗卡路里=运动强度×时长×体重系数 \text{消耗卡路里} = \text{运动强度} \times \text{时长} \times \text{体重系数} 消耗卡路里=运动强度×时长×体重系数

体重系数

体重系数=用户体重70 \text{体重系数} = \frac{\text{用户体重}}{70} 体重系数=70用户体重

实际计算

double calculateCalories({
  required double caloriesPerMinute,
  required int duration,
  required double weight,
}) {
  final weightFactor = weight / 70.0;
  return caloriesPerMinute * duration * weightFactor;
}

示例

  • 体重:70kg
  • 运动:跑步(10千卡/分钟)
  • 时长:30分钟
  • 消耗:10 × 30 × 1.0 = 300千卡

核心算法详解

1. 今日数据筛选算法

List<WorkoutRecord> getTodayRecords(List<WorkoutRecord> allRecords) {
  final now = DateTime.now();
  final today = DateTime(now.year, now.month, now.day);
  
  return allRecords.where((record) {
    final recordDate = DateTime(
      record.date.year,
      record.date.month,
      record.date.day,
    );
    return recordDate == today;
  }).toList();
}

时间复杂度:O(n)
空间复杂度:O(k),k为今日记录数

2. 7天统计算法

Map<DateTime, double> getLast7DaysCalories(List<WorkoutRecord> records) {
  final result = <DateTime, double>{};
  final now = DateTime.now();
  
  // 初始化7天数据
  for (int i = 6; i >= 0; i--) {
    final date = DateTime(now.year, now.month, now.day)
        .subtract(Duration(days: i));
    result[date] = 0;
  }
  
  // 累加每天的卡路里
  for (final record in records) {
    final dateKey = DateTime(
      record.date.year,
      record.date.month,
      record.date.day,
    );
    if (result.containsKey(dateKey)) {
      result[dateKey] = result[dateKey]! + record.calories;
    }
  }
  
  return result;
}

时间复杂度:O(n)
空间复杂度:O(7) = O(1)

3. 进度计算算法

double calculateProgress(double current, double target) {
  if (target <= 0) return 0;
  return (current / target).clamp(0.0, 1.0);
}

String getProgressText(double progress) {
  if (progress >= 1.0) return '目标已完成!';
  if (progress >= 0.8) return '即将完成';
  if (progress >= 0.5) return '进度过半';
  if (progress >= 0.3) return '继续加油';
  return '刚刚开始';
}

进度等级

  • 0-30%:刚刚开始
  • 30-50%:继续加油
  • 50-80%:进度过半
  • 80-100%:即将完成
  • 100%+:目标已完成

用户体验优化

1. 加载状态

class LoadingState extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          CircularProgressIndicator(),
          SizedBox(height: 16),
          Text('加载中...'),
        ],
      ),
    );
  }
}

2. 空状态

class EmptyState extends StatelessWidget {
  final String message;
  final IconData icon;
  final VoidCallback? onAction;
  final String? actionText;
  
  
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(icon, size: 80, color: Colors.grey.shade400),
          SizedBox(height: 16),
          Text(message, style: TextStyle(fontSize: 16)),
          if (onAction != null && actionText != null)
            Padding(
              padding: const EdgeInsets.only(top: 16),
              child: ElevatedButton(
                onPressed: onAction,
                child: Text(actionText!),
              ),
            ),
        ],
      ),
    );
  }
}

3. 错误处理

class ErrorHandler {
  static void handleError(BuildContext context, dynamic error) {
    String message = '操作失败';
    
    if (error is FormatException) {
      message = '数据格式错误';
    } else if (error is TimeoutException) {
      message = '操作超时';
    } else if (error is Exception) {
      message = error.toString();
    }
    
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.red,
        action: SnackBarAction(
          label: '重试',
          textColor: Colors.white,
          onPressed: () {
            // 重试逻辑
          },
        ),
      ),
    );
  }
}

测试建议

1. 单元测试

import 'package:flutter_test/flutter_test.dart';

void main() {
  group('卡路里计算测试', () {
    test('基础计算', () {
      final calories = calculateCalories(
        caloriesPerMinute: 10,
        duration: 30,
        weight: 70,
      );
      expect(calories, 300);
    });
    
    test('体重系数', () {
      final calories = calculateCalories(
        caloriesPerMinute: 10,
        duration: 30,
        weight: 80,
      );
      expect(calories, closeTo(342.86, 0.01));
    });
  });
  
  group('日期格式化测试', () {
    test('今天', () {
      final now = DateTime.now();
      final formatted = formatDate(now);
      expect(formatted, contains('今天'));
    });
    
    test('昨天', () {
      final yesterday = DateTime.now().subtract(Duration(days: 1));
      final formatted = formatDate(yesterday);
      expect(formatted, contains('昨天'));
    });
  });
}

2. Widget测试

void main() {
  testWidgets('卡路里卡片显示测试', (WidgetTester tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: CalorieCard(
            todayCalories: 300,
            progress: 0.6,
            goal: 500,
          ),
        ),
      ),
    );
    
    expect(find.text('300'), findsOneWidget);
    expect(find.text('/ 500 千卡'), findsOneWidget);
    expect(find.text('60% 完成'), findsOneWidget);
  });
}

3. 集成测试

void main() {
  testWidgets('添加运动流程测试', (WidgetTester tester) async {
    await tester.pumpWidget(MyApp());
    
    // 点击添加按钮
    await tester.tap(find.text('添加'));
    await tester.pumpAndSettle();
    
    // 选择运动类型
    await tester.tap(find.text('跑步'));
    await tester.pumpAndSettle();
    
    // 输入时长
    await tester.enterText(find.byType(TextField), '30');
    await tester.pumpAndSettle();
    
    // 确认添加
    await tester.tap(find.text('确定'));
    await tester.pumpAndSettle();
    
    // 验证记录已添加
    expect(find.text('跑步'), findsWidgets);
    expect(find.text('30分钟'), findsOneWidget);
  });
}

常见问题解决

1. SharedPreferences数据丢失

问题:应用重启后数据丢失

解决方案

// 确保数据保存完成
Future<void> saveData() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setStringList('key', data);
  await prefs.reload(); // 重新加载确保保存成功
}

2. 日期比较错误

问题:今日数据筛选不准确

解决方案

// 只比较日期部分,忽略时间
DateTime getDateOnly(DateTime dateTime) {
  return DateTime(dateTime.year, dateTime.month, dateTime.day);
}

bool isSameDay(DateTime date1, DateTime date2) {
  return getDateOnly(date1) == getDateOnly(date2);
}

3. 图表显示异常

问题:柱状图高度为0或超出范围

解决方案

// 使用clamp限制范围
final height = (value / maxValue * maxHeight).clamp(minHeight, maxHeight);

// 处理空数据
final maxValue = values.isEmpty ? 100.0 : values.reduce(max);

4. 内存泄漏

问题:长时间使用后应用卡顿

解决方案


void dispose() {
  _scrollController.dispose();
  _textController.dispose();
  super.dispose();
}

最佳实践

1. 代码组织

// 将常量提取到单独文件
class AppConstants {
  static const double defaultWeight = 70.0;
  static const double defaultCalorieGoal = 500.0;
  static const int chartDays = 7;
  static const int pageSize = 20;
}

// 使用枚举管理状态
enum LoadingState {
  initial,
  loading,
  loaded,
  error,
}

// 使用扩展方法
extension DateTimeExtension on DateTime {
  bool isSameDay(DateTime other) {
    return year == other.year && 
           month == other.month && 
           day == other.day;
  }
  
  String toDisplayString() {
    return '$year-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
  }
}

2. 性能优化

// 使用const构造函数
const Text('标题', style: TextStyle(fontSize: 18));

// 避免在build方法中创建对象
class MyWidget extends StatelessWidget {
  static const textStyle = TextStyle(fontSize: 16);
  
  
  Widget build(BuildContext context) {
    return Text('内容', style: textStyle);
  }
}

// 使用ListView.builder而不是ListView
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) => ItemWidget(items[index]),
)

3. 错误处理

// 使用try-catch包裹异步操作
Future<void> loadData() async {
  try {
    final prefs = await SharedPreferences.getInstance();
    final data = prefs.getStringList('key');
    // 处理数据
  } catch (e) {
    print('加载数据失败: $e');
    // 显示错误提示
  }
}

// 提供默认值
final weight = prefs.getDouble('weight') ?? 70.0;

4. 可维护性

// 使用命名参数
WorkoutRecord({
  required this.id,
  required this.exerciseName,
  required this.duration,
  required this.calories,
  required this.date,
  this.notes,
});

// 添加文档注释
/// 计算运动消耗的卡路里
/// 
/// [caloriesPerMinute] 每分钟消耗的卡路里
/// [duration] 运动时长(分钟)
/// [weight] 用户体重(千克)
/// 
/// 返回消耗的总卡路里数
double calculateCalories({
  required double caloriesPerMinute,
  required int duration,
  required double weight,
}) {
  return caloriesPerMinute * duration * (weight / 70.0);
}

总结

本项目实现了一个功能完整的健身追踪应用,涵盖以下核心技术:

  1. 数据模型:Exercise和WorkoutRecord模型设计
  2. 卡路里计算:基于运动类型和时长的智能计算
  3. 数据统计:今日数据、7天趋势、总体统计
  4. 数据持久化:SharedPreferences本地存储
  5. UI设计:卡片布局、图表展示、底部导航
  6. 交互体验:对话框、进度条、空状态处理

通过本教程,你可以学习到:

  • 复杂数据模型的设计和序列化
  • 日期时间的处理和格式化
  • 数据统计和图表绘制
  • 本地数据持久化方案
  • Material 3设计规范应用
  • 状态管理和UI更新

这个项目可以作为学习Flutter应用开发的实用案例,通过扩展GPS追踪、心率监测、社交分享等功能,可以打造更加专业的健身管理平台。

运动类型参考

运动类型 强度 每分钟消耗 适合人群
跳绳 12千卡 减脂人群
游泳 11千卡 全身锻炼
跑步 中高 10千卡 心肺训练
足球 中高 9.5千卡 团队运动
篮球 中高 9千卡 团队运动
爬山 中高 8.5千卡 户外爱好者
骑行 8千卡 有氧运动
羽毛球 7千卡 休闲运动
力量训练 6千卡 增肌人群
瑜伽 3千卡 柔韧性训练

健身建议

初学者

  • 每周3-4次运动
  • 每次30-45分钟
  • 选择中低强度运动
  • 循序渐进增加强度

进阶者

  • 每周4-5次运动
  • 每次45-60分钟
  • 结合有氧和力量训练
  • 注意休息和恢复

高级者

  • 每周5-6次运动
  • 每次60-90分钟
  • 制定专业训练计划
  • 关注营养和睡眠

注意事项

  • 运动前充分热身
  • 运动后拉伸放松
  • 及时补充水分
  • 循序渐进,避免受伤
  • 如有不适立即停止
    欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐