当前位置: 首页 > news >正文

Flutter 跨平台实战:OpenHarmony 健康管理应用 Day17|历史数据本地持久化与多记录存储实现

🎯Flutter 跨平台实战:OpenHarmony 健康管理应用 Day17|历史数据本地持久化与多记录存储实现

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

🚀前言

大家好,本篇是 Flutter+OpenHarmony 健康管理应用开发系列第十七篇笔记。在 Day15 引入 fl_chart 图表依赖、Day16 完成健康数据折线图绘制的基础上,今日实现多条健康数据本地持久化存储、历史记录列表展示、新增数据追加存储功能,解决原有单条数据覆盖问题,适配 OpenHarmony 鸿蒙环境稳定运行,代码可直接复制使用,与前文功能无缝衔接、不脱节。

💥 本文你能学到

  • 基于 SharedPreferences 实现多条健康数据 JSON 数组存储

  • 历史数据列表页面布局设计与数据渲染

  • 新增数据自动追加、避免覆盖原有记录,同步支撑折线图数据展示

  • 保留前期所有功能:表单校验、BMI 计算、页面动画、退出弹窗、fl_chart 图表展示、关于页面

  • 统一全局 UI 风格,兼容鸿蒙模拟器无布局错乱,确保与前面功能衔接流畅

🥝 开发环境

环境信息

  • 开发工具:DevEco Studio

  • 开发语言:Dart

  • 开发框架:Flutter

  • 调试设备:OpenHarmony 手机模拟器

  • 适配平台:OpenHarmony

依赖配置

沿用 Day15 引入的 fl_chart 图表依赖,新增 JSON 序列化相关处理(无需额外新增第三方库),确保与前文依赖衔接,同时适配鸿蒙环境无冲突

dependencies: flutter: sdk: flutter shared_preferences: ^2.2.2 fl_chart: ^0.55.2

📝 今日核心开发功能

  • 改造本地存储逻辑,支持多条健康数据以 JSON 格式持久化保存,为 Day16 折线图提供多组数据支撑

  • 新增历史记录页面,展示所有已录入的健康信息,与折线图数据同源、同步更新

  • 录入新数据时自动追加至列表,不再覆盖原有数据,同时同步更新折线图展示内容

  • 底部导航新增「历史记录」Tab,实现五页面平滑切换,与首页、图表相关页面衔接流畅

  • 完全兼容鸿蒙系统,保留 fl_chart 依赖且无适配冲突,运行稳定无闪退

  • 保留项目全部已有功能,不修改原有代码结构,确保与前面功能无缝衔接

✅ 完整可运行代码

import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; import 'package:fl_chart/fl_chart.dart'; // 还原Day15引入的fl_chart依赖,衔接前文图表功能 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 HealthRecord { final String name; final String gender; final String age; final String height; final String weight; final String heart; final String saveTime; HealthRecord({ required this.name, required this.gender, required this.age, required this.height, required this.weight, required this.heart, required this.saveTime, }); // JSON转对象 factory HealthRecord.fromJson(Map<String, dynamic> json) { return HealthRecord( name: json['name'], gender: json['gender'], age: json['age'], height: json['height'], weight: json['weight'], heart: json['heart'], saveTime: json['saveTime'], ); } // 对象转JSON Map<String, dynamic> toJson() { return { 'name': name, 'gender': gender, 'age': age, 'height': height, 'weight': weight, 'heart': heart, 'saveTime': saveTime, }; } } // 新增:图表数据处理工具类(衔接Day16折线图功能) class ChartDataUtil { // 从历史记录中提取体重数据,用于折线图展示 static List<FlSpot> getWeightSpots(List<HealthRecord> records) { List<FlSpot> spots = []; for (int i = 0; i < records.length; i++) { double weight = double.tryParse(records[i].weight) ?? 0.0; spots.add(FlSpot(i.toDouble(), weight)); } return spots; } // 从历史记录中提取心率数据,用于折线图展示 static List<FlSpot> getHeartSpots(List<HealthRecord> records) { List<FlSpot> spots = []; for (int i = 0; i < records.length; i++) { double heart = double.tryParse(records[i].heart) ?? 0.0; spots.add(FlSpot(i.toDouble(), heart)); } return spots; } } // 新增:图表展示页面(沿用Day16折线图逻辑,与历史记录数据联动) class ChartPage extends StatefulWidget { const ChartPage({super.key}); @override State<ChartPage> createState() => _ChartPageState(); } class _ChartPageState extends State<ChartPage> { List<HealthRecord> recordList = []; int _selectedTab = 0; // 0:体重折线图,1:心率折线图 Future<void> _loadHistoryRecords() async { SharedPreferences prefs = await SharedPreferences.getInstance(); String? recordStr = prefs.getString("health_records"); if (recordStr != null) { List<dynamic> jsonList = json.decode(recordStr); setState(() { recordList = jsonList.map((e) => HealthRecord.fromJson(e)).toList(); }); } } @override void initState() { super.initState(); _loadHistoryRecords(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("健康数据图表")), body: Column( children: [ // 图表切换Tab Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: _selectedTab == 0 ? Colors.teal : Colors.grey[200], foregroundColor: _selectedTab == 0 ? Colors.white : Colors.black, ), onPressed: () => setState(() => _selectedTab = 0), child: const Text("体重趋势"), ), const SizedBox(width: 20), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: _selectedTab == 1 ? Colors.teal : Colors.grey[200], foregroundColor: _selectedTab == 1 ? Colors.white : Colors.black, ), onPressed: () => setState(() => _selectedTab = 1), child: const Text("心率趋势"), ), ], ), const SizedBox(height: 20), // 折线图展示 Expanded( child: recordList.isEmpty ? const Center(child: Text("暂无健康数据,无法展示图表", style: TextStyle(fontSize: 16, color: Colors.grey))) : _selectedTab == 0 ? _buildWeightChart() : _buildHeartChart(), ), ], ), ); } // 体重折线图(沿用Day16逻辑) Widget _buildWeightChart() { List<FlSpot> spots = ChartDataUtil.getWeightSpots(recordList); return Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: LineChart( LineChartData( gridData: const FlGridData(show: true, drawBorder: false), titlesData: const FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles(showTitles: true, interval: 1, reservedSize: 20), ), leftTitles: AxisTitles( sideTitles: SideTitles(showTitles: true, interval: 5, reservedSize: 40), ), ), borderData: FlBorderData(show: true), minX: 0, maxX: spots.length > 0 ? spots.last.x : 0, minY: spots.isNotEmpty ? spots.map((e) => e.y).reduce((a, b) => a < b ? a : b) - 5 : 0, maxY: spots.isNotEmpty ? spots.map((e) => e.y).reduce((a, b) => a > b ? a : b) + 5 : 100, lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, color: Colors.teal, thickness: 3, dotData: const FlDotData(show: true), belowBarData: BarAreaData(show: true, color: Colors.teal.withOpacity(0.2)), ), ], ), ), ); } // 心率折线图(沿用Day16逻辑) Widget _buildHeartChart() { List<FlSpot> spots = ChartDataUtil.getHeartSpots(recordList); return Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: LineChart( LineChartData( gridData: const FlGridData(show: true, drawBorder: false), titlesData: const FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles(showTitles: true, interval: 1, reservedSize: 20), ), leftTitles: AxisTitles( sideTitles: SideTitles(showTitles: true, interval: 20, reservedSize: 40), ), ), borderData: FlBorderData(show: true), minX: 0, maxX: spots.length > 0 ? spots.last.x : 0, minY: 40, maxY: 180, lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, color: Colors.redAccent, thickness: 3, dotData: const FlDotData(show: true), belowBarData: BarAreaData(show: true, color: Colors.redAccent.withOpacity(0.2)), ), ], ), ), ); } } 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(), ChartPage(), // 还原Day16的图表页面,与历史记录页面衔接 HistoryRecordPage(), 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.bar_chart), label: "数据图表"), // 还原图表Tab,衔接前文 BottomNavigationBarItem(icon: Icon(Icons.history), 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)), ], ), ); } @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: 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(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); // 多条历史记录追加存储,同步更新图表数据 List<HealthRecord> records = []; String? recordStr = prefs.getString("health_records"); if (recordStr != null) { List<dynamic> jsonList = json.decode(recordStr); records = jsonList.map((e) => HealthRecord.fromJson(e)).toList(); } records.add(HealthRecord( name: name, gender: _gender, age: ageStr, height: heightStr, weight: weightStr, heart: heartStr, saveTime: nowTime, )); await prefs.setString("health_records", json.encode(records.map((e) => e.toJson()).toList())); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("保存成功,图表已同步更新"))); // 清空输入框 _nameController.clear(); _ageController.clear(); _heightController.clear(); _weightController.clear(); _heartController.clear(); } @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 HistoryRecordPage extends StatefulWidget { const HistoryRecordPage({super.key}); @override State<HistoryRecordPage> createState() => _HistoryRecordPageState(); } class _HistoryRecordPageState extends State<HistoryRecordPage> { List<HealthRecord> recordList = []; Future<void> _loadHistoryRecords() async { SharedPreferences prefs = await SharedPreferences.getInstance(); String? recordStr = prefs.getString("health_records"); if (recordStr != null) { List<dynamic> jsonList = json.decode(recordStr); setState(() { recordList = jsonList.map((e) => HealthRecord.fromJson(e)).toList(); }); } } @override void initState() { super.initState(); _loadHistoryRecords(); } Widget _buildRecordItem(HealthRecord record) { return Card( margin: EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text("录入时间:${record.saveTime}", style: TextStyle(color: Colors.grey[600])), SizedBox(height: 10), Text("姓名:${record.name} 性别:${record.gender}"), SizedBox(height: 6), Text("年龄:${record.age}岁 身高:${record.height}cm"), SizedBox(height: 6), Text("体重:${record.weight}kg 心率:${record.heart}次/分"), ], ), ), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("历史记录")), body: recordList.isEmpty ? Center(child: Text("暂无历史健康记录,录入数据后可同步展示图表", style: TextStyle(fontSize: 16, color: Colors.grey))) : ListView.builder( padding: EdgeInsets.symmetric(vertical: 10), itemCount: recordList.length, itemBuilder: (context, index) => _buildRecordItem(recordList[index]), ), ); } } 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.7", 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体质指数自动计算、本地数据持久化存储、多条历史记录保存、fl_chart健康数据折线图展示、录入时间记录、全局UI美化、页面跳转动画、返回键退出弹窗等完整功能,各功能模块无缝衔接。", style: TextStyle(fontSize: 15, height: 1.6), ), SizedBox(height: 20), Text("技术栈", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(height: 12), Text( "Flutter + Dart + SharedPreferences 本地存储 + JSON序列化 + fl_chart图表", style: TextStyle(fontSize: 15), ), SizedBox(height: 20), Text("开发用途", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), SizedBox(height: 12), Text( "课程实训综合项目,完整覆盖页面布局、表单校验、多条数据存储、JSON序列化、列表渲染、图表展示、业务逻辑、UI美化、交互优化等核心开发知识点,功能连贯、结构完整。", style: TextStyle(fontSize: 15, height: 1.6), ), ], ), ), ), ], ), ), ); } }

📱 调试与运行完整步骤

  1. 停止原有项目运行

  2. 添加 fl_chart 依赖(已在配置中写好),终端执行 flutter pub get

    flutter clean flutter pub get
  3. 修改 lib\main.dart 代码,无需修改其他文件

  4. 连接鸿蒙模拟器,执行 flutter run

    flutter run
  5. 底部导航切换「首页」「历史记录」页面,验证数据同步展示

  6. 多次录入健康数据,验证数据自动追加、图表同步更新、历史记录正常显示

  7. 测试五个页面切换、动画、退出弹窗、数据存储、BMI计算、图表展示全部正常

  8. 布局适配鸿蒙,无错位、无遮挡、无依赖冲突

🔐 跨平台适配说明

本次保留 Day15 引入的 fl_chart: ^0.55.2 版本(经测试适配鸿蒙模拟器,无闪退、无编译报错),新增历史记录页面与 JSON 持久化逻辑,与 Day16 功能无缝衔接。所有功能完全遵循鸿蒙Flutter开发规范,沿用原有SharedPreferences稳定方案,在OpenHarmony模拟器中运行流畅,完美兼容历史所有功能,确保上下文衔接不脱节。

❌ 常见错误排查

错误现象

解决方法

历史记录不显示、图表无数据

检查JSON序列化/反序列化逻辑,确保数据正确编码存储;执行flutter pub get 确认fl_chart依赖加载成功

底部导航挤压文字

保持BottomNavigationBarType.fixed模式,适配多Tab显示

数据仍被覆盖

确认先读取原有记录再追加,而非直接覆盖赋值;检查saveData方法中JSON数组处理逻辑

图表无法渲染、报错

确保fl_chart版本为^0.55.2,与鸿蒙环境适配;检查ChartDataUtil工具类中数据转换逻辑,避免空数据导致报错

🎨 项目后续规划

Day17完成多条健康数据持久化、历史记录展示,同时还原fl_chart图表功能,确保与Day16衔接流畅;Day18将进行最终代码精简、项目总结与结业收尾,优化细节、梳理完整开发流程,整套健康管理项目正式闭环。

📌 项目总结

本篇Day17在Day16的基础上,实现JSON格式多条健康数据本地存储、历史记录列表展示、数据追加保存功能,同时还原fl_chart图表依赖与图表页面,确保各功能模块无缝衔接、上下文连贯。底部导航升级为六页面结构,彻底解决原有单条数据覆盖问题,项目数据层、展示层能力大幅完善,同时保持与鸿蒙系统高度兼容,为最终Day18项目收尾奠定基础。

✅ 结尾小贴士

  • 多条数据存储必须使用JSON数组序列化处理,直接覆盖会丢失历史数据
  • fl_chart依赖需使用适配鸿蒙的版本(如^0.55.2),避免版本过高导致适配冲突
  • 底部导航多Tab必须配置fixed模式,保证鸿蒙设备UI显示正常
  • 历史记录与图表数据同源,确保数据同步更新、衔接流畅
  • 点赞收藏不迷路,最后一日开发笔记持续同步更新
http://www.jsqmd.com/news/792442/

相关文章:

  • 【maaath】 Flutter for OpenHarmony足球计时应用开发实战
  • 2026年怎么报名小自考畜牧兽医专业?专科畜牧兽医的科目是什么?官方助学点吴老师 15902813070 深度解析! - 知名不具123
  • 别再手动翻译了!用Python的googletrans库5分钟搞定批量文件翻译(附实战代码)
  • MLIR编译器中的并行优化技术解析
  • OpenCore Legacy Patcher深度指南:让老旧Mac焕发新生的完整实战手册
  • 通过curl命令直接测试taotoken平台api接口的详细步骤
  • 恒盛通跨境电商物流的客户案例(二) - 恒盛通物流
  • 世界模型:高维智能的优势、风险与现实边界
  • MongoDB 覆盖索引查询
  • 一文分清Agent与Skill
  • 初创团队如何利用taotoken实现api密钥的统一管理与访问控制
  • 3步解锁电脑隐藏性能:UXTU硬件调优实战指南
  • Redis模糊查询实战:从keys到scan的演进与避坑指南
  • 抖音批量下载终极指南:5分钟学会免费下载无水印视频
  • ThreeFingerDragOnWindows:在Windows上实现macOS三指拖动的终极指南
  • WebPages 对象
  • 免费开源AMD Ryzen调试工具:SMUDebugTool完整指南
  • Linux系统上如何安装哔哩哔哩客户端:完整功能指南与配置技巧
  • 《Python脚本到OpenClaw技能:解锁Agent原生能力的转换指南》
  • 从磁带机到物联网:LRC纵向冗余校验的‘复古’算法,为何今天还在用?
  • 【Java EE】网络通信中的 4 种交互模式
  • 体验 Taotoken 官方价折扣与活动价带来的实际成本节省
  • 从Prompt Gateway到Content SLA引擎:2026奇点大会上最受瞩目的5个开源组件,已集成至CNCF沙箱(限前500名开发者获取部署手册)
  • 从拿订单到看方向
  • 分布式架构下的Switch游戏文件处理:NSC_BUILDER技术深度解析
  • 从VGG到ResNet-152:图解经典网络进化史,看“跳连接”如何开启深度学习新篇章
  • 《OpenClaw语义采集:让机器第一次真正读懂网页》
  • 艾尔登法环修改器2026.5.10最新更新中文汉化版免费下载(看到速度转存 资源随时可能失效
  • 信息安全工程师-入侵阻断与网络流量清洗技术详解
  • 模型广场功能让开发者轻松对比与选择合适的大模型