🎯Flutter 跨平台实战:OpenHarmony 健康管理应用 Day16|健康数据折线图绘制

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

🚀 前言

大家好,本篇是 Flutter+OpenHarmony 健康管理应用开发系列第十六篇笔记。基于 Day15 已引入fl_chart图表依赖、搭建可视化基础框架,今日完成健康数据折线图完整绘制,将身高、体重、心率数据渲染到折线图中,适配鸿蒙模拟器正常显示,保留前期所有功能不变。

💥 本文你能学到

  • fl_chart 折线图 LineChart 完整配置写法
  • 静态模拟健康数据绑定折线图坐标轴
  • 图表样式、线条颜色、圆点标记、坐标轴自定义
  • Flutter 图表组件在鸿蒙系统的适配技巧
  • 不改动原有业务逻辑,新增图表可视化效果

🥝 开发环境

1. 环境信息

  • 开发工具:DevEco Studio
  • 开发语言:Dart
  • 开发框架:Flutter
  • 调试设备:OpenHarmony 手机模拟器
  • 适配平台:OpenHarmony

2. 依赖配置

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
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  int _currentIndex = 0;
  final List<Widget> _pages = const [
    HomePage(),
    HealthInputPage(),
    ProfilePage(),
    AboutPage(),
  ];

  void _onItemTapped(int index) {
    setState(() {
      _currentIndex = index;
    });
  }

  Future<bool> _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
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  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 = "肥胖";
    }
  }

  Future<void> _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
  State<HealthInputPage> createState() => _HealthInputPageState();
}

class _HealthInputPageState extends State<HealthInputPage> {
  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}";
  }

  Future<void> _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
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> {
  String name = "未填写";
  String gender = "未填写";
  String age = "未填写";
  String height = "未填写";
  String weight = "未填写";
  String heart = "未填写";
  String saveTime = "暂无记录时间";

  Future<void> _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") ?? "暂无记录时间";
    });
  }

  Future<void> _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),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

📱 调试与运行步骤

  1. 确认 pubspec.yaml 已添加双第三方依赖并执行flutter pub get
  2. 替换项目 lib/main.dart 为当前完整源码
  3. 连接 OpenHarmony 鸿蒙模拟器
  4. 运行项目,首页可查看已渲染完成的健康趋势折线图
  5. 原有信息录入、数据保存、BMI 计算、清空数据、退出弹窗功能均正常可用

🔐 鸿蒙适配说明

本次折线图组件完全兼容 OpenHarmony 系统,通过固定图表高度、合理配置坐标轴与内边距,避免横竖屏布局溢出,图表线条、圆点、渐变阴影在鸿蒙端渲染正常无错位、无黑屏。

❌ 常见错误排查

错误现象 解决方法
fl_chart 导入报错 检查 yaml 依赖是否添加并执行 pub get
图表组件黑屏 / 不显示 确认组件嵌套结构、高度约束配置正确
鸿蒙端图表异常 保持基础 LineChartData 配置正确,固定高度防止溢出

🎨 项目后续规划

Day16 已完成健康数据折线图绘制,下一篇 Day17 将进行鸿蒙系统横竖屏布局适配,优化应用在不同屏幕方向下的页面展示效果。

📌 项目总结

本篇 Day16 严格按照开发路线完成健康数据折线图完整绘制,在 Day15 可视化基础上完成 fl_chart 折线图全自定义配置,实现健康数据趋势展示,整体项目结构完整、功能迭代连贯,适配鸿蒙模拟器。

✅ 结尾小贴士

  • fl_chart 是 Flutter 生态中成熟稳定的图表第三方库,在 OpenHarmony 鸿蒙系统中兼容性良好
  • 只需正确配置依赖并设置容器高度即可正常使用,无需额外适
  • 点赞收藏不迷路,后续每日开发笔记将持续同步更新
Logo

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

更多推荐