【maaath】 Flutter for OpenHarmony 跨平台健康计步应用开发实践
本文详细介绍了如何使用 Flutter for OpenHarmony 从零构建一个功能完整的健康计步应用。我们从数据模型设计出发,使用 Dart 语言实现了步数记录、运动目标、成就徽章和排行榜等核心实体的定义,并通过Provider模式构建了统一的数据管理层。在 UI 层面,我们利用 Flutter 丰富的组件库实现了环形进度条、数据图表、列表渲染、网格布局等多种交互形式,并结合 Materia
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_chart 的 BarChart 组件提供了丰富的自定义选项。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 都可以通过 Consumer 或 context.read 访问数据。命名路由的配置方式让页面跳转更加清晰和可维护。
十一、运行截图
以下为应用在鸿蒙设备上的实际运行效果截图:
- 步数统计首页 - 展示环形进度条、今日步数、卡路里、里程、时长等核心数据
- 运动目标设置 - 展示每日步数/每周步数/卡路里/里程目标的滑块设置界面
- 运动历史记录 - 展示近7天的步数记录列表,包含达标状态标识
- 数据图表页面 - 展示 fl_chart 绘制的近7天步数柱状图
- 排行榜页面 - 展示用户步数排名,前三名奖牌标识
- 成就徽章页面 - 展示已解锁和未解锁的成就徽章列表
十二、总结与展望
本文详细介绍了如何使用 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 实现健康数据同步、添加社交分享功能等,让这款健康计步工具更加实用和强大。
技术要点回顾:
- 数据模型设计:使用不可变对象 +
copyWith模式,确保状态变更可追踪 - 状态管理:
ChangeNotifier+Provider组合,实现高效的数据驱动 UI 更新 - 图表可视化:
fl_chart库的BarChart组件,支持触摸交互和自定义样式 - 成就系统:基于进度计算的自动解锁机制,激励用户持续运动
- 排行榜:列表渲染 + 视觉编码(奖牌/颜色/边框),提升社交互动体验
本文为原创内容,代码已在 OpenHarmony 设备上验证通过。
更多推荐








所有评论(0)