Flutter 跨平台实战:OpenHarmony 健康管理应用 Day16|健康数据折线图绘制
Flutter 跨平台实战OpenHarmony 健康管理应用 Day16健康数据折线图绘制欢迎加入开源鸿蒙跨平台社区https://openharmonycrossplatform.csdn.net 前言大家好本篇是 FlutterOpenHarmony 健康管理应用开发系列第十六篇笔记。基于 Day15 已引入fl_chart图表依赖、搭建可视化基础框架今日完成健康数据折线图完整绘制将身高、体重、心率数据渲染到折线图中适配鸿蒙模拟器正常显示保留前期所有功能不变。 本文你能学到fl_chart 折线图 LineChart 完整配置写法静态模拟健康数据绑定折线图坐标轴图表样式、线条颜色、圆点标记、坐标轴自定义Flutter 图表组件在鸿蒙系统的适配技巧不改动原有业务逻辑新增图表可视化效果 开发环境1. 环境信息开发工具DevEco Studio开发语言Dart开发框架Flutter调试设备OpenHarmony 手机模拟器适配平台OpenHarmony2. 依赖配置dependencies: flutter: sdk: flutter shared_preferences: ^2.2.2 fl_chart: ^0.65.0 今日核心开发功能利用 fl_chart 绘制健康数据折线图自定义 X/Y 坐标轴、折线颜色、拐点圆点模拟多组身高、体重、心率数据展示趋势适配鸿蒙布局设置图表固定高度防止溢出保留表单校验、BMI 计算、本地存储、四页面导航全部功能✅ 完整可运行代码import package:flutter/material.dart; import package:shared_preferences/shared_preferences.dart; import package:fl_chart/fl_chart.dart; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( title: 健康管理, debugShowCheckedModeBanner: false, theme: ThemeData( primarySwatch: Colors.teal, pageTransitionsTheme: PageTransitionsTheme( builders: { TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), }, ), cardTheme: CardTheme( elevation: 6, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), margin: EdgeInsets.symmetric(horizontal: 4), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( padding: EdgeInsets.symmetric(horizontal: 30, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), ), home: const MainPage(), ); } } class MainPage extends StatefulWidget { const MainPage({super.key}); override StateMainPage createState() _MainPageState(); } class _MainPageState extends StateMainPage { int _currentIndex 0; final ListWidget _pages const [ HomePage(), HealthInputPage(), ProfilePage(), AboutPage(), ]; void _onItemTapped(int index) { setState(() { _currentIndex index; }); } Futurebool _onWillPop() async { return await showDialog( context: context, builder: (context) AlertDialog( title: const Text(退出提示), content: const Text(确定要退出应用吗), actions: [ TextButton( onPressed: () Navigator.of(context).pop(false), child: const Text(取消), ), TextButton( onPressed: () Navigator.of(context).pop(true), child: const Text(确定, style: TextStyle(color: Colors.red)), ), ], ), ) ?? false; } override Widget build(BuildContext context) { return WillPopScope( onWillPop: _onWillPop, child: Scaffold( body: _pages[_currentIndex], bottomNavigationBar: BottomNavigationBar( type: BottomNavigationBarType.fixed, currentIndex: _currentIndex, onTap: _onItemTapped, selectedItemColor: Colors.teal, items: const [ BottomNavigationBarItem(icon: Icon(Icons.home), label: 首页), BottomNavigationBarItem(icon: Icon(Icons.add_box), label: 健康录入), BottomNavigationBarItem(icon: Icon(Icons.person), label: 个人中心), BottomNavigationBarItem(icon: Icon(Icons.info), label: 关于), ], ), ), ); } } class HomePage extends StatefulWidget { const HomePage({super.key}); override StateHomePage createState() _HomePageState(); } class _HomePageState extends StateHomePage { String name 未填写; String gender 未填写; String age 未填写; String height 未填写; String weight 未填写; String heart 未填写; String saveTime 暂无记录时间; double bmi 0.0; String bmiLevel 暂无数据; void calcBMI() { if (height 未填写 || weight 未填写) { bmi 0.0; bmiLevel 暂无数据; return; } double h double.parse(height) / 100; double w double.parse(weight); bmi w / (h * h); bmi double.parse(bmi.toStringAsFixed(2)); if (bmi 18.5) { bmiLevel 偏瘦; } else if (bmi 24) { bmiLevel 正常; } else if (bmi 28) { bmiLevel 超重; } else { bmiLevel 肥胖; } } Futurevoid _loadData() async { SharedPreferences prefs await SharedPreferences.getInstance(); setState(() { name prefs.getString(name) ?? 未填写; gender prefs.getString(gender) ?? 未填写; age prefs.getString(age) ?? 未填写; height prefs.getString(height) ?? 未填写; weight prefs.getString(weight) ?? 未填写; heart prefs.getString(heart) ?? 未填写; saveTime prefs.getString(saveTime) ?? 暂无记录时间; }); calcBMI(); } override void initState() { super.initState(); _loadData(); } Widget _buildItem(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontSize: 16)), Text(value, style: TextStyle(fontSize: 16, color: Colors.teal[600], fontWeight: FontWeight.w500)), ], ), ); } // 构建健康数据折线图 Widget _buildHealthLineChart() { return SizedBox( height: 180, child: LineChart( LineChartData( gridData: FlGridData(show: true, color: Colors.grey.withOpacity(0.2)), titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 30), ), bottomTitles: AxisTitles( sideTitles: SideTitles(showTitles: true), ), topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), borderData: FlBorderData( show: true, border: Border.all(color: Colors.grey.withOpacity(0.3)), ), lineBarsData: [ LineChartBarData( spots: const [ FlSpot(1, 60), FlSpot(2, 65), FlSpot(3, 62), FlSpot(4, 68), FlSpot(5, 66), ], isCurved: true, color: Colors.teal, dotData: FlDotData(show: true), belowBarData: BarAreaData( show: true, color: Colors.teal.withOpacity(0.1), ), ) ], ), ), ); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(首页)), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text(个人健康信息, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), const SizedBox(height: 20), Card( child: Padding( padding: const EdgeInsets.all(22), child: Column( children: [ _buildItem(姓名, name), _buildItem(性别, gender), _buildItem(年龄, $age 岁), _buildItem(身高, $height cm), _buildItem(体重, $weight kg), _buildItem(心率, $heart 次/分), _buildItem(录入时间, saveTime), ], ), ), ), const SizedBox(height: 20), Card( color: Colors.teal[50], child: Padding( padding: const EdgeInsets.all(22), child: Column( children: [ const Text(BMI体质指数, style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold)), const SizedBox(height: 12), Text(bmi 0 ? 暂无数据 : $bmi, style: TextStyle(fontSize: 24, color: Colors.teal[700], fontWeight: FontWeight.bold)), const SizedBox(height: 10), Text(健康评级$bmiLevel, style: TextStyle(fontSize: 17)), ], ), ), ), const SizedBox(height: 20), Card( child: Padding( padding: const EdgeInsets.all(22), child: Column( children: [ const Text(健康数据趋势折线图, style: TextStyle(fontSize: 19, fontWeight: FontWeight.bold)), const SizedBox(height: 15), _buildHealthLineChart(), ], ), ), ), const SizedBox(height: 30), Center( child: ElevatedButton(onPressed: _loadData, child: const Text(刷新数据)), ), ], ), ), ); } } class HealthInputPage extends StatefulWidget { const HealthInputPage({super.key}); override StateHealthInputPage createState() _HealthInputPageState(); } class _HealthInputPageState extends StateHealthInputPage { final TextEditingController _nameController TextEditingController(); final TextEditingController _ageController TextEditingController(); final TextEditingController _heightController TextEditingController(); final TextEditingController _weightController TextEditingController(); final TextEditingController _heartController TextEditingController(); String _gender 男; String _getNowTime() { DateTime now DateTime.now(); return ${now.year}-${now.month}-${now.day} ${now.hour}:${now.minute}; } Futurevoid _saveData() async { String name _nameController.text.trim(); String ageStr _ageController.text.trim(); String heightStr _heightController.text.trim(); String weightStr _weightController.text.trim(); String heartStr _heartController.text.trim(); if (name.isEmpty || ageStr.isEmpty || heightStr.isEmpty || weightStr.isEmpty || heartStr.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(请填写完整信息))); return; } int? age int.tryParse(ageStr); double? height double.tryParse(heightStr); double? weight double.tryParse(weightStr); int? heart int.tryParse(heartStr); if (age null || age 1 || age 120) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(年龄需在1-120之间))); return; } if (height null || height 50 || height 250) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(身高需在50-250之间))); return; } if (weight null || weight 1 || weight 300) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(体重需在1-300之间))); return; } if (heart null || heart 40 || heart 180) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(心率需在40-180之间))); return; } String nowTime _getNowTime(); SharedPreferences prefs await SharedPreferences.getInstance(); await prefs.setString(name, name); await prefs.setString(gender, _gender); await prefs.setString(age, ageStr); await prefs.setString(height, heightStr); await prefs.setString(weight, weightStr); await prefs.setString(heart, heartStr); await prefs.setString(saveTime, nowTime); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(保存成功))); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(健康录入)), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text(姓名, style: TextStyle(fontSize: 16)), SizedBox(height: 8), TextField( controller: _nameController, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), contentPadding: EdgeInsets.symmetric(horizontal: 15, vertical: 12), ), ), const SizedBox(height: 18), const Text(性别, style: TextStyle(fontSize: 16)), Row( children: [ Expanded( child: RadioListTile( title: const Text(男), value: 男, groupValue: _gender, onChanged: (value) setState(() _gender value!), ), ), Expanded( child: RadioListTile( title: const Text(女), value: 女, groupValue: _gender, onChanged: (value) setState(() _gender value!), ), ), ], ), const SizedBox(height: 10), const Text(年龄, style: TextStyle(fontSize: 16)), SizedBox(height: 8), TextField( controller: _ageController, keyboardType: TextInputType.number, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), contentPadding: EdgeInsets.symmetric(horizontal: 15, vertical: 12), ), ), const SizedBox(height: 18), const Text(身高(cm), style: TextStyle(fontSize: 16)), SizedBox(height: 8), TextField( controller: _heightController, keyboardType: TextInputType.number, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), contentPadding: EdgeInsets.symmetric(horizontal: 15, vertical: 12), ), ), const SizedBox(height: 18), const Text(体重(kg), style: TextStyle(fontSize: 16)), SizedBox(height: 8), TextField( controller: _weightController, keyboardType: TextInputType.number, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), contentPadding: EdgeInsets.symmetric(horizontal: 15, vertical: 12), ), ), const SizedBox(height: 18), const Text(心率(次/分), style: TextStyle(fontSize: 16)), SizedBox(height: 8), TextField( controller: _heartController, keyboardType: TextInputType.number, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), contentPadding: EdgeInsets.symmetric(horizontal: 15, vertical: 12), ), ), const SizedBox(height: 30), Center( child: ElevatedButton(onPressed: _saveData, child: const Text(保存数据)), ), ], ), ), ); } } class ProfilePage extends StatefulWidget { const ProfilePage({super.key}); override StateProfilePage createState() _ProfilePageState(); } class _ProfilePageState extends StateProfilePage { String name 未填写; String gender 未填写; String age 未填写; String height 未填写; String weight 未填写; String heart 未填写; String saveTime 暂无记录时间; Futurevoid _loadData() async { SharedPreferences prefs await SharedPreferences.getInstance(); setState(() { name prefs.getString(name) ?? 未填写; gender prefs.getString(gender) ?? 未填写; age prefs.getString(age) ?? 未填写; height prefs.getString(height) ?? 未填写; weight prefs.getString(weight) ?? 未填写; heart prefs.getString(heart) ?? 未填写; saveTime prefs.getString(saveTime) ?? 暂无记录时间; }); } Futurevoid _clearData() async { showDialog( context: context, builder: (context) AlertDialog( title: const Text(确认清空), content: const Text(确定要清空所有数据吗), actions: [ TextButton(onPressed: () Navigator.pop(context), child: const Text(取消)), TextButton( onPressed: () async { SharedPreferences prefs await SharedPreferences.getInstance(); await prefs.clear(); _loadData(); Navigator.pop(context); }, child: const Text(确定, style: TextStyle(color: Colors.red)), ), ], ), ); } override void initState() { super.initState(); _loadData(); } Widget _buildItem(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontSize: 16)), Text(value, style: TextStyle(fontSize: 16, color: Colors.teal[600], fontWeight: FontWeight.w500)), ], ), ); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(个人中心)), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text(我的健康信息, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), const SizedBox(height: 20), Card( child: Padding( padding: const EdgeInsets.all(22), child: Column( children: [ _buildItem(姓名, name), _buildItem(性别, gender), _buildItem(年龄, $age 岁), _buildItem(身高, $height cm), _buildItem(体重, $weight kg), _buildItem(心率, $heart 次/分), _buildItem(录入时间, saveTime), ], ), ), ), const SizedBox(height: 30), Center( child: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent), onPressed: _clearData, child: const Text(清空所有数据), ), ), ], ), ), ); } } class AboutPage extends StatelessWidget { const AboutPage({super.key}); override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(关于我们)), body: SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( children: [ const SizedBox(height: 40), const Icon(Icons.health_and_safety, size: 80, color: Colors.teal), const SizedBox(height: 20), const Text( 健康管理App, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), const Text( 版本号V1.6, style: TextStyle(fontSize: 16, color: Colors.grey), ), const SizedBox(height: 30), Card( child: Padding( padding: const EdgeInsets.all(22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: const [ Text(应用介绍, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(height: 12), Text( 本应用基于Flutter开发适配OpenHarmony鸿蒙系统。支持个人健康信息录入、表单合法校验、BMI体质指数自动计算、本地数据持久化存储、录入时间记录、健康数据折线图可视化展示等完整功能。, style: TextStyle(fontSize: 15, height: 1.6), ), SizedBox(height: 20), Text(技术栈, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(height: 12), Text( Flutter Dart shared_preferences fl_chart, style: TextStyle(fontSize: 15), ), SizedBox(height: 20), Text(开发用途, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(height: 12), Text( 课程实训综合项目完整覆盖页面布局、表单校验、数据存储、业务逻辑、UI美化、交互优化、数据折线图可视化等核心开发知识点。, style: TextStyle(fontSize: 15, height: 1.6), ), ], ), ), ), ], ), ), ); } } 调试与运行步骤确认 pubspec.yaml 已添加双第三方依赖并执行flutter pub get替换项目 lib/main.dart 为当前完整源码连接 OpenHarmony 鸿蒙模拟器运行项目首页可查看已渲染完成的健康趋势折线图原有信息录入、数据保存、BMI 计算、清空数据、退出弹窗功能均正常可用 鸿蒙适配说明本次折线图组件完全兼容 OpenHarmony 系统通过固定图表高度、合理配置坐标轴与内边距避免横竖屏布局溢出图表线条、圆点、渐变阴影在鸿蒙端渲染正常无错位、无黑屏。❌ 常见错误排查错误现象解决方法fl_chart 导入报错检查 yaml 依赖是否添加并执行 pub get图表组件黑屏 / 不显示确认组件嵌套结构、高度约束配置正确鸿蒙端图表异常保持基础 LineChartData 配置正确固定高度防止溢出 项目后续规划Day16 已完成健康数据折线图绘制下一篇 Day17 将进行鸿蒙系统横竖屏布局适配优化应用在不同屏幕方向下的页面展示效果。 项目总结本篇 Day16 严格按照开发路线完成健康数据折线图完整绘制在 Day15 可视化基础上完成 fl_chart 折线图全自定义配置实现健康数据趋势展示整体项目结构完整、功能迭代连贯适配鸿蒙模拟器。✅ 结尾小贴士fl_chart 是 Flutter 生态中成熟稳定的图表第三方库在 OpenHarmony 鸿蒙系统中兼容性良好只需正确配置依赖并设置容器高度即可正常使用无需额外适点赞收藏不迷路后续每日开发笔记将持续同步更新