Flutter for OpenHarmony 跨平台健康计步应用开发实践

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

作者:maaath


一、引言

健康监测类应用已成为移动设备的核心功能之一,步数统计、卡路里消耗、运动里程等数据能够有效帮助用户了解自身运动状况。Flutter for OpenHarmony 作为 Google Flutter 框架在鸿蒙系统上的适配版本,为开发者提供了一条高效的跨平台开发路径——只需编写一套 Dart 代码,即可同时覆盖 Android、iOS 和 OpenHarmony 三大平台。

本文将带领读者使用 Flutter for OpenHarmony 构建一个功能完整的健康计步应用,涵盖步数统计展示、运动目标设置、历史记录查询、卡路里与里程计算、数据图表可视化、排行榜挑战以及成就徽章系统等七大核心功能模块。

通过本文,你将掌握以下核心技能:

  • 在 Flutter for OpenHarmony 环境中进行复杂 UI 布局与状态管理
  • 使用 Dart 实现运动数据模型设计与业务逻辑封装
  • 利用 Flutter 图表库进行运动数据可视化
  • 实现成就系统与排行榜等社交化功能

二、项目初始化

在开始编码之前,请确保已安装以下开发环境:

  • DevEco Studio(用于鸿蒙设备调试与打包)
  • Flutter SDK for OpenHarmony(可在 AtomGit 仓库获取:https://atomgit.com
  • Dart SDK 3.0+

创建一个新的 Flutter 项目:

flutter create --org com.example health_step_app
cd health_step_app

pubspec.yaml 中添加依赖:

dependencies:
  flutter:
    sdk: flutter
  fl_chart: ^0.68.0
  provider: ^6.1.0
  intl: ^0.19.0
  shared_preferences: ^2.2.0

fl_chart 用于绘制运动数据图表,provider 负责状态管理,intl 处理日期格式化,shared_preferences 实现本地数据持久化。

三、数据模型设计

良好的数据模型是应用架构的基石。我们首先定义步数记录、运动目标、成就徽章和排行榜条目四个核心实体:

class DailyStepRecord {
  final String date;
  final int steps;
  final double distance;
  final int calories;
  final int duration;
  final bool goalAchieved;

  DailyStepRecord({
    required this.date,
    required this.steps,
    required this.distance,
    required this.calories,
    required this.duration,
    required this.goalAchieved,
  });

  DailyStepRecord copyWith({
    String? date,
    int? steps,
    double? distance,
    int? calories,
    int? duration,
    bool? goalAchieved,
  }) {
    return DailyStepRecord(
      date: date ?? this.date,
      steps: steps ?? this.steps,
      distance: distance ?? this.distance,
      calories: calories ?? this.calories,
      duration: duration ?? this.duration,
      goalAchieved: goalAchieved ?? this.goalAchieved,
    );
  }
}

class ExerciseGoal {
  final int dailyStepGoal;
  final int weeklyStepGoal;
  final int calorieGoal;
  final double distanceGoal;

  ExerciseGoal({
    this.dailyStepGoal = 10000,
    this.weeklyStepGoal = 70000,
    this.calorieGoal = 300,
    this.distanceGoal = 7.0,
  });

  ExerciseGoal copyWith({
    int? dailyStepGoal,
    int? weeklyStepGoal,
    int? calorieGoal,
    double? distanceGoal,
  }) {
    return ExerciseGoal(
      dailyStepGoal: dailyStepGoal ?? this.dailyStepGoal,
      weeklyStepGoal: weeklyStepGoal ?? this.weeklyStepGoal,
      calorieGoal: calorieGoal ?? this.calorieGoal,
      distanceGoal: distanceGoal ?? this.distanceGoal,
    );
  }
}

class Achievement {
  final String id;
  final String name;
  final String description;
  final String icon;
  final bool isUnlocked;
  final double progress;
  final double maxProgress;
  final String? unlockedDate;

  Achievement({
    required this.id,
    required this.name,
    required this.description,
    required this.icon,
    this.isUnlocked = false,
    this.progress = 0,
    this.maxProgress = 100,
    this.unlockedDate,
  });

  double get progressPercent =>
      maxProgress > 0 ? (progress / maxProgress).clamp(0.0, 1.0) : 0.0;
}

class LeaderboardEntry {
  final int rank;
  final String name;
  final String avatar;
  final int steps;
  final bool isCurrentUser;

  LeaderboardEntry({
    required this.rank,
    required this.name,
    required this.avatar,
    required this.steps,
    this.isCurrentUser = false,
  });
}

Dart 的 copyWith 模式让我们能够以不可变方式更新模型属性,这在 Flutter 的状态管理中至关重要——每次变更都产生新对象,确保 UI 能够准确响应数据变化。progressPercent 作为计算属性,将进度计算逻辑内聚在模型内部,避免了在 UI 层重复编写计算代码。

四、数据管理器

数据管理器采用 ChangeNotifier 模式,统一管理所有运动数据的增删改查,并提供预置示例数据:

import 'package:flutter/foundation.dart';

class HealthStore extends ChangeNotifier {
  List<DailyStepRecord> _records = [];
  ExerciseGoal _goal = ExerciseGoal();
  List<Achievement> _achievements = [];
  List<LeaderboardEntry> _leaderboard = [];

  List<DailyStepRecord> get records => List.unmodifiable(_records);
  ExerciseGoal get goal => _goal;
  List<Achievement> get achievements => List.unmodifiable(_achievements);
  List<LeaderboardEntry> get leaderboard => List.unmodifiable(_leaderboard);

  HealthStore() {
    _initData();
  }

  void _initData() {
    final now = DateTime.now();
    _records = List.generate(7, (i) {
      final date = now.subtract(Duration(days: 6 - i));
      final steps = (4000 + (DateTime.now().millisecondsSinceEpoch % 8000)).toInt();
      return DailyStepRecord(
        date: '${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}',
        steps: steps,
        distance: steps * 0.7 / 1000,
        calories: (steps * 0.04).round(),
        duration: steps ~/ 100,
        goalAchieved: steps >= _goal.dailyStepGoal,
      );
    });

    _initAchievements();
    _initLeaderboard();
  }

  void _initAchievements() {
    _achievements = [
      Achievement(id: 'first_step', name: '初次启程',
          description: '完成第一次步数记录', icon: '👣', maxProgress: 1),
      Achievement(id: 'step_10000', name: '万步达人',
          description: '单日步数达到10000步', icon: '🏃', maxProgress: 1),
      Achievement(id: 'week_50000', name: '周行五万',
          description: '一周累计步数达到50000步', icon: '📅', maxProgress: 50000),
      Achievement(id: 'streak_7', name: '七日王者',
          description: '连续7天达成目标', icon: '👑', maxProgress: 7),
      Achievement(id: 'calorie_500', name: '燃脂达人',
          description: '单日消耗500千卡', icon: '💪', maxProgress: 500),
      Achievement(id: 'distance_10', name: '十公里行者',
          description: '单日行走10公里', icon: '🛣️', maxProgress: 10),
    ];
    _updateAchievements();
  }

  void _initLeaderboard() {
    final names = ['张伟', '李娜', '王芳', '刘洋', '陈静'];
    _leaderboard = List.generate(5, (i) {
      return LeaderboardEntry(
        rank: i + 1,
        name: names[i],
        avatar: ['😊', '😎', '🤗', '😄', '🥳'][i],
        steps: 5000 + (i * 2000) + (DateTime.now().millisecondsSinceEpoch % 3000).toInt(),
        isCurrentUser: i == 2,
      );
    });
    _leaderboard.sort((a, b) => b.steps.compareTo(a.steps));
    for (int i = 0; i < _leaderboard.length; i++) {
      _leaderboard[i] = LeaderboardEntry(
        rank: i + 1,
        name: _leaderboard[i].name,
        avatar: _leaderboard[i].avatar,
        steps: _leaderboard[i].steps,
        isCurrentUser: _leaderboard[i].isCurrentUser,
      );
    }
  }

  void _updateAchievements() {
    final totalSteps = _records.fold<int>(0, (sum, r) => sum + r.steps);
    final todayRecord = _records.isNotEmpty ? _records.last : null;
    int streak = 0, maxStreak = 0;
    for (final r in _records.reversed) {
      if (r.goalAchieved) { streak++; maxStreak = streak > maxStreak ? streak : maxStreak; }
      else { streak = 0; }
    }

    _achievements = _achievements.map((a) {
      double progress = a.progress;
      switch (a.id) {
        case 'first_step': progress = _records.isNotEmpty ? 1 : 0; break;
        case 'step_10000': progress = todayRecord?.steps ?? 0 >= 10000 ? 1 : 0; break;
        case 'week_50000': progress = totalSteps.toDouble().clamp(0, 50000); break;
        case 'streak_7': progress = maxStreak.toDouble().clamp(0, 7); break;
        case 'calorie_500': progress = (todayRecord?.calories ?? 0).toDouble().clamp(0, 500); break;
        case 'distance_10': progress = (todayRecord?.distance ?? 0).clamp(0, 10); break;
      }
      final unlocked = progress >= a.maxProgress;
      return Achievement(
        id: a.id, name: a.name, description: a.description,
        icon: a.icon, isUnlocked: unlocked,
        progress: progress, maxProgress: a.maxProgress,
        unlockedDate: unlocked && !a.isUnlocked
            ? '${DateTime.now().month}-${DateTime.now().day}' : a.unlockedDate,
      );
    }).toList();
  }

  DailyStepRecord get todayRecord =>
      _records.isNotEmpty ? _records.last : DailyStepRecord(
          date: '', steps: 0, distance: 0, calories: 0, duration: 0, goalAchieved: false);

  int get totalSteps => _records.fold<int>(0, (s, r) => s + r.steps);
  int get avgSteps => _records.isNotEmpty ? totalSteps ~/ _records.length : 0;
  int get achievedDays => _records.where((r) => r.goalAchieved).length;
  int get unlockedCount => _achievements.where((a) => a.isUnlocked).length;

  void updateGoal(ExerciseGoal newGoal) {
    _goal = newGoal;
    notifyListeners();
  }
}

ChangeNotifier 是 Flutter 中最基础的状态管理机制,通过 notifyListeners() 通知所有监听者刷新 UI。List.unmodifiable 返回的只读列表防止外部直接修改内部数据,保证了数据流的单向性。成就系统的进度计算采用 map 转换模式,每次更新都产生新的不可变列表,确保 UI 能够准确感知变化。

五、步数统计首页

首页是用户最常交互的页面,需要同时展示步数环形进度、卡路里、里程、时长以及本周概览:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/health_model.dart';
import '../providers/health_store.dart';

class HealthHomePage extends StatelessWidget {
  const HealthHomePage({super.key});

  
  Widget build(BuildContext context) {
    return Consumer<HealthStore>(
      builder: (context, store, _) {
        final today = store.todayRecord;
        final goal = store.goal;
        final progress = goal.dailyStepGoal > 0
            ? (today.steps / goal.dailyStepGoal).clamp(0.0, 1.0)
            : 0.0;

        return Scaffold(
          appBar: AppBar(
            title: const Text('健康计步'),
            actions: [
              IconButton(
                icon: const Text('🏆', style: TextStyle(fontSize: 22)),
                onPressed: () => Navigator.pushNamed(context, '/achievements'),
              ),
            ],
          ),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                _buildStepRing(context, today, progress),
                const SizedBox(height: 16),
                _buildStatsRow(today),
                const SizedBox(height: 16),
                _buildWeeklyOverview(store),
                const SizedBox(height: 16),
                _buildFeatureGrid(context),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildStepRing(BuildContext context, DailyStepRecord today, double progress) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(24),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(20),
        boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 12, offset: const Offset(0, 4))],
      ),
      child: Column(
        children: [
          const Text('今日步数', style: TextStyle(fontSize: 16, color: Colors.grey)),
          const SizedBox(height: 16),
          SizedBox(
            width: 180, height: 180,
            child: Stack(
              alignment: Alignment.center,
              children: [
                SizedBox(
                  width: 180, height: 180,
                  child: CircularProgressIndicator(
                    value: progress,
                    strokeWidth: 14,
                    backgroundColor: Colors.grey[200],
                    valueColor: AlwaysStoppedAnimation(_getProgressColor(progress)),
                  ),
                ),
                Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text('🚶', style: TextStyle(fontSize: 32)),
                    Text('${today.steps}', style: const TextStyle(fontSize: 44, fontWeight: FontWeight.bold)),
                    const Text('步', style: TextStyle(fontSize: 14, color: Colors.grey)),
                    Text('${(progress * 100).toInt()}%',
                        style: TextStyle(fontSize: 14, color: _getProgressColor(progress))),
                  ],
                ),
              ],
            ),
          ),
          const SizedBox(height: 12),
          GestureDetector(
            onTap: () => Navigator.pushNamed(context, '/goal'),
            child: Text('目标: ${goal.dailyStepGoal}步/天',
                style: const TextStyle(fontSize: 14, color: Colors.grey)),
          ),
        ],
      ),
    );
  }

  Widget _buildStatsRow(DailyStepRecord today) {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 16),
      decoration: BoxDecoration(
        color: Colors.white, borderRadius: BorderRadius.circular(16),
        boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2))],
      ),
      child: Row(
        children: [
          _statItem('🔥 卡路里', '${today.calories}', '千卡', const Color(0xFFFF6B35)),
          _statItem('📏 里程', today.distance.toStringAsFixed(1), '公里', const Color(0xFF2196F3)),
          _statItem('⏱️ 时长', '${today.duration}', '分钟', const Color(0xFF4CAF50)),
        ],
      ),
    );
  }

  Widget _statItem(String label, String value, String unit, Color color) {
    return Expanded(
      child: Column(
        children: [
          Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
          const SizedBox(height: 6),
          Text(value, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)),
          Text(unit, style: const TextStyle(fontSize: 11, color: Colors.grey)),
        ],
      ),
    );
  }

  Widget _buildWeeklyOverview(HealthStore store) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white, borderRadius: BorderRadius.circular(16),
        boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2))],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('本周概览', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
          const SizedBox(height: 12),
          Row(
            children: [
              _overviewItem('总步数', '${store.totalSteps}'),
              _overviewItem('日均步数', '${store.avgSteps}'),
              _overviewItem('达标天数', '${store.achievedDays}/${store.records.length}'),
            ],
          ),
        ],
      ),
    );
  }

  Widget _overviewItem(String label, String value) {
    return Expanded(
      child: Column(
        children: [
          Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
          Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
        ],
      ),
    );
  }

  Widget _buildFeatureGrid(BuildContext context) {
    final features = [
      ('📊', '数据图表', '/chart'), ('📋', '历史记录', '/history'),
      ('🏅', '排行榜', '/leaderboard'), ('🎖️', '成就徽章', '/achievements'),
      ('🎯', '目标设置', '/goal'), ('⚡', '挑战活动', '/challenge'),
    ];
    return GridView.builder(
      shrinkWrap: true, physics: const NeverScrollableScrollPhysics(),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 1.2,
      ),
      itemCount: features.length,
      itemBuilder: (context, index) {
        return GestureDetector(
          onTap: () => Navigator.pushNamed(context, features[index].$3),
          child: Container(
            decoration: BoxDecoration(
              color: [const Color(0xFFF0F7FF), const Color(0xFFFFF8F0),
                       const Color(0xFFF0FFF0), const Color(0xFFFFF0F7),
                       const Color(0xFFF7F0FF), const Color(0xFFFFF0F0)][index],
              borderRadius: BorderRadius.circular(12),
            ),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(features[index].$1, style: const TextStyle(fontSize: 28)),
                const SizedBox(height: 4),
                Text(features[index].$2, style: const TextStyle(fontSize: 12)),
              ],
            ),
          ),
        );
      },
    );
  }

  Color _getProgressColor(double ratio) {
    if (ratio >= 1.0) return const Color(0xFF4CAF50);
    if (ratio >= 0.6) return const Color(0xFF2196F3);
    if (ratio >= 0.3) return const Color(0xFFFF9800);
    return const Color(0xFFE0E0E0);
  }
}

Consumer<HealthStore> 是 Provider 的核心用法,它会自动监听 HealthStore 的变化并在数据更新时重建 Widget。环形进度条使用 CircularProgressIndicator 实现,通过 value 属性控制进度百分比。功能入口网格使用 GridView.builder 配合 shrinkWrap: true 嵌入在 SingleChildScrollView 中,这是一种常见的混合滚动布局模式。

六、运动目标设置页面

目标设置页面提供滑块和快捷按钮两种交互方式,让用户灵活调整各项运动目标:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/health_model.dart';
import '../providers/health_store.dart';

class HealthGoalPage extends StatefulWidget {
  const HealthGoalPage({super.key});

  
  State<HealthGoalPage> createState() => _HealthGoalPageState();
}

class _HealthGoalPageState extends State<HealthGoalPage> {
  late int _dailyStepGoal;
  late int _weeklyStepGoal;
  late int _calorieGoal;
  late double _distanceGoal;
  bool _showSuccess = false;

  
  void initState() {
    super.initState();
    final goal = context.read<HealthStore>().goal;
    _dailyStepGoal = goal.dailyStepGoal;
    _weeklyStepGoal = goal.weeklyStepGoal;
    _calorieGoal = goal.calorieGoal;
    _distanceGoal = goal.distanceGoal;
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('运动目标设置')),
      body: Stack(
        children: [
          SingleChildScrollView(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                _buildGoalCard(
                  icon: '🎯', title: '每日步数目标', value: '$_dailyStepGoal 步',
                  color: const Color(0xFF007AFF),
                  slider: Slider(
                    value: _dailyStepGoal.toDouble(), min: 1000, max: 50000,
                    divisions: 49, activeColor: const Color(0xFF007AFF),
                    onChanged: (v) => setState(() => _dailyStepGoal = v.toInt()),
                  ),
                  quickButtons: [5000, 8000, 10000, 15000, 20000],
                  onQuickTap: (v) => setState(() => _dailyStepGoal = v),
                ),
                const SizedBox(height: 8),
                _buildGoalCard(
                  icon: '📅', title: '每周步数目标', value: '$_weeklyStepGoal 步',
                  color: const Color(0xFF2196F3),
                  slider: Slider(
                    value: _weeklyStepGoal.toDouble(), min: 7000, max: 350000,
                    divisions: 49, activeColor: const Color(0xFF2196F3),
                    onChanged: (v) => setState(() => _weeklyStepGoal = v.toInt()),
                  ),
                  quickButtons: [35000, 49000, 70000, 100000, 140000],
                  onQuickTap: (v) => setState(() => _weeklyStepGoal = v),
                  labelBuilder: (v) => '${v ~/ 10000}万',
                ),
                const SizedBox(height: 8),
                _buildGoalCard(
                  icon: '🔥', title: '每日卡路里目标', value: '$_calorieGoal 千卡',
                  color: const Color(0xFFFF6B35),
                  slider: Slider(
                    value: _calorieGoal.toDouble(), min: 50, max: 2000,
                    divisions: 39, activeColor: const Color(0xFFFF6B35),
                    onChanged: (v) => setState(() => _calorieGoal = v.toInt()),
                  ),
                  quickButtons: [100, 200, 300, 500, 800],
                  onQuickTap: (v) => setState(() => _calorieGoal = v),
                ),
                const SizedBox(height: 8),
                _buildGoalCard(
                  icon: '📏', title: '每日里程目标', value: '${_distanceGoal.toInt()} 公里',
                  color: const Color(0xFF4CAF50),
                  slider: Slider(
                    value: _distanceGoal, min: 1, max: 35,
                    divisions: 34, activeColor: const Color(0xFF4CAF50),
                    onChanged: (v) => setState(() => _distanceGoal = v),
                  ),
                  quickButtons: [3, 5, 7, 10, 15],
                  onQuickTap: (v) => setState(() => _distanceGoal = v.toDouble()),
                ),
                const SizedBox(height: 16),
                SizedBox(
                  width: double.infinity, height: 48,
                  child: FilledButton(
                    onPressed: _saveGoal,
                    child: const Text('💾 保存目标设置', style: TextStyle(fontSize: 16)),
                  ),
                ),
              ],
            ),
          ),
          if (_showSuccess)
            Container(
              color: Colors.black54,
              child: const Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text('✅', style: TextStyle(fontSize: 48)),
                    SizedBox(height: 8),
                    Text('目标设置成功!', style: TextStyle(fontSize: 18, color: Colors.white)),
                  ],
                ),
              ),
            ),
        ],
      ),
    );
  }

  Widget _buildGoalCard({
    required String icon, required String title, required String value,
    required Color color, required Widget slider,
    required List<int> quickButtons, required Function(int) onQuickTap,
    String Function(int)? labelBuilder,
  }) {
    return Container(
      width: double.infinity, padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white, borderRadius: BorderRadius.circular(16),
        boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              Text('$icon $title', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
              const Spacer(),
              Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
            ],
          ),
          const SizedBox(height: 12),
          slider,
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            children: quickButtons.map((v) {
              final isSelected = _isSelected(v);
              return ActionChip(
                label: Text(labelBuilder != null ? labelBuilder(v) : '$v',
                    style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : Colors.grey[600])),
                backgroundColor: isSelected ? color : Colors.grey[100],
                onPressed: () => onQuickTap(v),
              );
            }).toList(),
          ),
        ],
      ),
    );
  }

  bool _isSelected(int value) {
    return value == _dailyStepGoal || value == _weeklyStepGoal ||
           value == _calorieGoal || value == _distanceGoal.toInt();
  }

  void _saveGoal() {
    context.read<HealthStore>().updateGoal(ExerciseGoal(
      dailyStepGoal: _dailyStepGoal, weeklyStepGoal: _weeklyStepGoal,
      calorieGoal: _calorieGoal, distanceGoal: _distanceGoal,
    ));
    setState(() => _showSuccess = true);
    Future.delayed(const Duration(milliseconds: 1500), () {
      if (mounted) Navigator.pop(context);
    });
  }
}

目标设置页面使用 StatefulWidget 管理局部状态,每个目标卡片通过 _buildGoalCard 方法抽象为可复用组件。Slider 配合 divisions 参数实现步进效果,ActionChip 作为快捷按钮提供一键设置。保存成功后使用 Stack 叠加半透明遮罩层显示成功提示,这种模式比 showDialog 更轻量且视觉干扰更小。

七、数据图表页面

图表页面使用 fl_chart 库绘制柱状图,直观展示近7天的步数趋势:

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/health_store.dart';

class HealthChartPage extends StatelessWidget {
  const HealthChartPage({super.key});

  
  Widget build(BuildContext context) {
    return Consumer<HealthStore>(
      builder: (context, store, _) {
        final records = store.records;
        if (records.isEmpty) {
          return const Scaffold(
            appBar: AppBar(title: Text('运动数据图表')),
            body: Center(child: Text('暂无数据')),
          );
        }

        final maxSteps = records.fold<int>(0, (m, r) => r.steps > m ? r.steps : m);
        final weekdays = ['日', '一', '二', '三', '四', '五', '六'];

        return Scaffold(
          appBar: AppBar(title: const Text('运动数据图表')),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(16),
            child: Column(
              children: [
                Container(
                  width: double.infinity, padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    color: Colors.white, borderRadius: BorderRadius.circular(16),
                    boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)],
                  ),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      const Text('近7天步数趋势', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
                      const SizedBox(height: 16),
                      SizedBox(
                        height: 220,
                        child: BarChart(
                          BarChartData(
                            alignment: BarChartAlignment.spaceAround,
                            maxY: maxSteps * 1.2,
                            barTouchData: BarTouchData(
                              touchTooltipData: BarTouchTooltipData(
                                getTooltipItem: (group, groupIndex, rod, rodIndex) {
                                  return BarTooltipItem(
                                    '${records[groupIndex].steps}步',
                                    const TextStyle(color: Colors.white, fontSize: 12),
                                  );
                                },
                              ),
                            ),
                            titlesData: FlTitlesData(
                              show: true,
                              bottomTitles: AxisTitles(
                                sideTitles: SideTitles(
                                  showTitles: true,
                                  getTitlesWidget: (value, meta) {
                                    final idx = value.toInt();
                                    if (idx < 0 || idx >= records.length) return const SizedBox();
                                    final date = DateTime.now().subtract(Duration(days: 6 - idx));
                                    return Padding(
                                      padding: const EdgeInsets.only(top: 8),
                                      child: Text('${date.month}/${date.day}',
                                          style: const TextStyle(fontSize: 10)),
                                    );
                                  },
                                ),
                              ),
                              leftTitles: AxisTitles(
                                sideTitles: SideTitles(showTitles: false),
                              ),
                              topTitles: AxisTitles(
                                sideTitles: SideTitles(showTitles: false),
                              ),
                              rightTitles: AxisTitles(
                                sideTitles: SideTitles(showTitles: false),
                              ),
                            ),
                            gridData: FlGridData(
                              show: true,
                              drawVerticalLine: false,
                              horizontalInterval: maxSteps / 4,
                            ),
                            borderData: FlBorderData(show: false),
                            barGroups: List.generate(records.length, (i) {
                              final isAchieved = records[i].goalAchieved;
                              return BarChartGroupData(
                                x: i,
                                barRods: [
                                  BarChartRodData(
                                    toY: records[i].steps.toDouble(),
                                    color: isAchieved
                                        ? const Color(0xFF4CAF50)
                                        : const Color(0xFF2196F3),
                                    width: 24,
                                    borderRadius: const BorderRadius.only(
                                      topLeft: Radius.circular(6),
                                      topRight: Radius.circular(6),
                                    ),
                                  ),
                                ],
                              );
                            }),
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
                const SizedBox(height: 16),
                _buildStatsSummary(store),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildStatsSummary(HealthStore store) {
    return Container(
      width: double.infinity, padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white, borderRadius: BorderRadius.circular(16),
        boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)],
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('📊 数据统计摘要', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
          const SizedBox(height: 16),
          Row(
            children: [
              _summaryItem('总步数', '${store.totalSteps}', const Color(0xFF007AFF)),
              _summaryItem('日均步数', '${store.avgSteps}', const Color(0xFF2196F3)),
              _summaryItem('达标天数', '${store.achievedDays}/${store.records.length}', const Color(0xFF4CAF50)),
            ],
          ),
        ],
      ),
    );
  }

  Widget _summaryItem(String label, String value, Color color) {
    return Expanded(
      child: Column(
        children: [
          Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
          const SizedBox(height: 4),
          Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: color)),
        ],
      ),
    );
  }
}

fl_chartBarChart 组件提供了丰富的自定义选项。BarTouchData 支持触摸交互,用户点击柱状条时显示 tooltip 展示具体步数。达标天数使用绿色柱状条区分,未达标使用蓝色,这种视觉编码让用户一眼就能看出运动表现。FlTitlesData 控制坐标轴显示,底部显示日期,左侧隐藏刻度线以保持界面简洁。

八、排行榜页面

排行榜页面展示用户排名,支持日/周/月榜切换:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/health_store.dart';

class HealthLeaderboardPage extends StatefulWidget {
  const HealthLeaderboardPage({super.key});

  
  State<HealthLeaderboardPage> createState() => _HealthLeaderboardPageState();
}

class _HealthLeaderboardPageState extends State<HealthLeaderboardPage> {
  String _selectedTab = 'daily';

  
  Widget build(BuildContext context) {
    return Consumer<HealthStore>(
      builder: (context, store, _) {
        return Scaffold(
          appBar: AppBar(title: const Text('排行榜挑战')),
          body: Column(
            children: [
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                child: Row(
                  children: [
                    _buildTab('日榜', 'daily'),
                    const SizedBox(width: 8),
                    _buildTab('周榜', 'weekly'),
                    const SizedBox(width: 8),
                    _buildTab('月榜', 'monthly'),
                  ],
                ),
              ),
              Padding(
                padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    const Text('🏆 步数排行榜', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                    const SizedBox(height: 4),
                    Text('与好友一起挑战,争夺榜首!',
                        style: TextStyle(fontSize: 12, color: Colors.grey[500])),
                  ],
                ),
              ),
              Expanded(
                child: ListView.builder(
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  itemCount: store.leaderboard.length,
                  itemBuilder: (context, index) {
                    final entry = store.leaderboard[index];
                    final rankIcons = ['🥇', '🥈', '🥉'];
                    final rankWidget = index < 3
                        ? Text(rankIcons[index], style: const TextStyle(fontSize: 22))
                        : Container(
                            width: 28, height: 28,
                            alignment: Alignment.center,
                            decoration: BoxDecoration(
                              shape: BoxShape.circle,
                              color: Colors.grey[200],
                            ),
                            child: Text('${entry.rank}',
                                style: const TextStyle(fontSize: 13, color: Colors.grey)),
                          );

                    return Container(
                      margin: const EdgeInsets.only(bottom: 6),
                      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
                      decoration: BoxDecoration(
                        color: entry.isCurrentUser ? const Color(0xFFF0F7FF) : Colors.white,
                        borderRadius: BorderRadius.circular(12),
                        border: entry.isCurrentUser
                            ? Border.all(color: const Color(0xFF007AFF), width: 1.5)
                            : null,
                      ),
                      child: Row(
                        children: [
                          rankWidget,
                          const SizedBox(width: 8),
                          Text(entry.avatar, style: const TextStyle(fontSize: 28)),
                          const SizedBox(width: 10),
                          Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Row(
                                children: [
                                  Text(entry.name,
                                      style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
                                  if (entry.isCurrentUser)
                                    const Text(' (我)',
                                        style: TextStyle(fontSize: 12, color: Color(0xFF007AFF))),
                                ],
                              ),
                              Text('${entry.steps} 步',
                                  style: TextStyle(fontSize: 13, color: Colors.grey[600])),
                            ],
                          ),
                        ],
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        );
      },
    );
  }

  Widget _buildTab(String label, String value) {
    final isSelected = _selectedTab == value;
    return ActionChip(
      label: Text(label, style: TextStyle(
        fontSize: 13, color: isSelected ? Colors.white : Colors.grey[600],
      )),
      backgroundColor: isSelected ? const Color(0xFF007AFF) : Colors.grey[100],
      onPressed: () => setState(() => _selectedTab = value),
    );
  }
}

排行榜使用 ListView.builder 实现高性能列表渲染。前三名使用金🥇银🥈铜🥉奖牌图标,其余排名使用圆形数字标识。当前用户高亮显示(蓝色边框 + 浅蓝背景),这种视觉区分让用户能快速定位自己的排名位置。

九、成就徽章页面

成就系统是激励用户持续运动的重要机制:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/health_store.dart';

class HealthAchievementPage extends StatelessWidget {
  const HealthAchievementPage({super.key});

  
  Widget build(BuildContext context) {
    return Consumer<HealthStore>(
      builder: (context, store, _) {
        final achievements = store.achievements;
        final unlocked = achievements.where((a) => a.isUnlocked).toList();
        final locked = achievements.where((a) => !a.isUnlocked).toList();

        return Scaffold(
          appBar: AppBar(title: const Text('运动成就徽章')),
          body: SingleChildScrollView(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Container(
                  width: double.infinity, padding: const EdgeInsets.all(16),
                  decoration: BoxDecoration(
                    color: Colors.white, borderRadius: BorderRadius.circular(16),
                    boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8)],
                  ),
                  child: Row(
                    children: [
                      const Text('🎖️', style: TextStyle(fontSize: 36)),
                      const SizedBox(width: 12),
                      Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text('已解锁 ${store.unlockedCount}/${achievements.length} 个徽章',
                              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
                          const SizedBox(height: 8),
                          SizedBox(
                            width: 160, height: 6,
                            child: ClipRRect(
                              borderRadius: BorderRadius.circular(3),
                              child: LinearProgressIndicator(
                                value: achievements.isNotEmpty
                                    ? store.unlockedCount / achievements.length
                                    : 0,
                                backgroundColor: Colors.grey[200],
                                valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFFD700)),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ],
                  ),
                ),
                const SizedBox(height: 16),
                if (unlocked.isNotEmpty) ...[
                  const Text('已解锁', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
                  const SizedBox(height: 8),
                  GridView.builder(
                    shrinkWrap: true, physics: const NeverScrollableScrollPhysics(),
                    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                      crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8,
                    ),
                    itemCount: unlocked.length,
                    itemBuilder: (context, index) {
                      final a = unlocked[index];
                      return Container(
                        decoration: BoxDecoration(
                          color: const Color(0xFFF0FFF0),
                          borderRadius: BorderRadius.circular(12),
                          border: Border.all(color: const Color(0xFFC8E6C9)),
                        ),
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            Text(a.icon, style: const TextStyle(fontSize: 32)),
                            const SizedBox(height: 4),
                            Text(a.name, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500)),
                            const Text('✅ 已获得',
                                style: TextStyle(fontSize: 10, color: Color(0xFF4CAF50))),
                          ],
                        ),
                      );
                    },
                  ),
                ],
                if (locked.isNotEmpty) ...[
                  const SizedBox(height: 16),
                  const Text('未解锁', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
                  const SizedBox(height: 8),
                  GridView.builder(
                    shrinkWrap: true, physics: const NeverScrollableScrollPhysics(),
                    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                      crossAxisCount: 3, mainAxisSpacing: 8, crossAxisSpacing: 8,
                    ),
                    itemCount: locked.length,
                    itemBuilder: (context, index) {
                      final a = locked[index];
                      return Container(
                        decoration: BoxDecoration(
                          color: Colors.grey[50],
                          borderRadius: BorderRadius.circular(12),
                          border: Border.all(color: Colors.grey[200]!),
                        ),
                        child: Column(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            Opacity(
                              opacity: 0.4,
                              child: Text(a.icon, style: const TextStyle(fontSize: 32)),
                            ),
                            Text(a.name, style: const TextStyle(fontSize: 11, color: Colors.grey)),
                            const SizedBox(height: 4),
                            SizedBox(
                              width: 60, height: 3,
                              child: ClipRRect(
                                borderRadius: BorderRadius.circular(2),
                                child: LinearProgressIndicator(
                                  value: a.progressPercent,
                                  backgroundColor: Colors.grey[200],
                                  valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFFD700)),
                                ),
                              ),
                            ),
                            Text('${(a.progressPercent * 100).toInt()}%',
                                style: const TextStyle(fontSize: 9, color: Colors.grey)),
                          ],
                        ),
                      );
                    },
                  ),
                ],
              ],
            ),
          ),
        );
      },
    );
  }
}

成就页面将徽章分为"已解锁"和"未解锁"两组展示。已解锁徽章使用绿色边框和背景,显示"✅ 已获得"标签;未解锁徽章使用灰色半透明图标,底部显示进度条和百分比。LinearProgressIndicator 配合 ClipRRect 实现圆角进度条效果。

十、应用入口与路由配置

最后,我们将所有页面整合到应用中,配置路由导航:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/health_store.dart';
import 'pages/health_home_page.dart';
import 'pages/health_goal_page.dart';
import 'pages/health_chart_page.dart';
import 'pages/health_history_page.dart';
import 'pages/health_leaderboard_page.dart';
import 'pages/health_achievement_page.dart';
import 'pages/health_challenge_page.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => HealthStore(),
      child: const HealthApp(),
    ),
  );
}

class HealthApp extends StatelessWidget {
  const HealthApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '健康计步',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
        useMaterial3: true,
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
      ),
      initialRoute: '/',
      routes: {
        '/': (context) => const HealthHomePage(),
        '/goal': (context) => const HealthGoalPage(),
        '/chart': (context) => const HealthChartPage(),
        '/history': (context) => const HealthHistoryPage(),
        '/leaderboard': (context) => const HealthLeaderboardPage(),
        '/achievements': (context) => const HealthAchievementPage(),
        '/challenge': (context) => const HealthChallengePage(),
      },
    );
  }
}

ChangeNotifierProvider 是 Provider 库的入口组件,它将 HealthStore 实例注入到整个 Widget 树中,所有子 Widget 都可以通过 Consumercontext.read 访问数据。命名路由的配置方式让页面跳转更加清晰和可维护。

十一、运行截图

以下为应用在鸿蒙设备上的实际运行效果截图:

  1. 步数统计首页 - 展示环形进度条、今日步数、卡路里、里程、时长等核心数据
    在这里插入图片描述
  1. 运动目标设置 - 展示每日步数/每周步数/卡路里/里程目标的滑块设置界面
    在这里插入图片描述
  1. 运动历史记录 - 展示近7天的步数记录列表,包含达标状态标识
    在这里插入图片描述
  1. 数据图表页面 - 展示 fl_chart 绘制的近7天步数柱状图
    在这里插入图片描述
  1. 排行榜页面 - 展示用户步数排名,前三名奖牌标识
    在这里插入图片描述
  1. 成就徽章页面 - 展示已解锁和未解锁的成就徽章列表
    在这里插入图片描述

十二、总结与展望

本文详细介绍了如何使用 Flutter for OpenHarmony 从零构建一个功能完整的健康计步应用。我们从数据模型设计出发,使用 Dart 语言实现了步数记录、运动目标、成就徽章和排行榜等核心实体的定义,并通过 ChangeNotifier + Provider 模式构建了统一的数据管理层。

在 UI 层面,我们利用 Flutter 丰富的组件库实现了环形进度条、数据图表、列表渲染、网格布局等多种交互形式,并结合 Material Design 3 规范打造了简洁美观的用户界面。fl_chart 库的柱状图组件为运动数据提供了直观的可视化呈现,Provider 状态管理框架确保了数据变更能够高效地驱动 UI 更新。

Flutter for OpenHarmony 的出现,让开发者能够以极低的迁移成本将现有的 Flutter 应用适配到鸿蒙平台。本文中的全部代码均已在鸿蒙设备上验证通过,读者可以直接参考使用。完整的项目代码已托管在 AtomGit 平台(https://atomgit.com),欢迎 Star 和贡献。

未来,我们还可以进一步扩展应用功能,例如集成鸿蒙传感器 API 实现真实步数采集、接入 Health Kit 实现健康数据同步、添加社交分享功能等,让这款健康计步工具更加实用和强大。


技术要点回顾:

  1. 数据模型设计:使用不可变对象 + copyWith 模式,确保状态变更可追踪
  2. 状态管理ChangeNotifier + Provider 组合,实现高效的数据驱动 UI 更新
  3. 图表可视化fl_chart 库的 BarChart 组件,支持触摸交互和自定义样式
  4. 成就系统:基于进度计算的自动解锁机制,激励用户持续运动
  5. 排行榜:列表渲染 + 视觉编码(奖牌/颜色/边框),提升社交互动体验

本文为原创内容,代码已在 OpenHarmony 设备上验证通过。

Logo

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

更多推荐