import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import '../../../models/health.dart'; /// 体重折线图(含理想目标线)。 /// /// 和 `donut_chart.dart` 一个纪律:**fl_chart 的类型只出现在这个文件里**。 /// 页面只传数据进来,升级图表库时只需要改这一处。 /// /// 两条线: /// · 实测体重(实线,圆点) /// · 理想体重(虚线,来自目标)—— 一眼看出「落后了还是超前了」 class WeightLineChart extends StatelessWidget { const WeightLineChart({ super.key, required this.points, this.idealCurve = const [], this.height = 220, }); /// 实测体重点(按日期升序) final List points; /// 理想体重曲线,为空就不画 final List idealCurve; final double height; @override Widget build(BuildContext context) { if (points.isEmpty) { return SizedBox( height: height, child: const Center( child: Text( '还没有体重记录', style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)), ), ), ); } // X 轴用「距首日天数」而不是时间戳:时间戳在 fl_chart 里是毫秒级的大数, // 坐标轴标签会很难格式化,换算成天数后间隔和标签都很直观。 final base = points.first.date; double dayOf(DateTime d) => d.difference(base).inDays.toDouble(); final actual = points .map((p) => FlSpot(dayOf(p.date), p.weight)) .toList(growable: false); // 理想曲线的起点可能早于实测(比如先设目标后开始记体重), // 所以基准日要取两者更早的那个,否则理想线会被挤到左边界外 final ideal = []; for (final p in idealCurve) { final x = dayOf(p.date); // 只画在实测数据的可见区间内,避免 X 轴被拉得过长 if (x >= 0 && x <= dayOf(points.last.date)) { ideal.add(FlSpot(x, p.weight)); } } final (minY, maxY) = _yRange(actual, ideal); final maxX = dayOf(points.last.date); return SizedBox( height: height, child: LineChart( LineChartData( minX: 0, maxX: maxX <= 0 ? 1 : maxX, minY: minY, maxY: maxY, gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: ((maxY - minY) / 4).clamp(0.5, 100), getDrawingHorizontalLine: (_) => const FlLine( color: Color(0xFFF0EAE4), strokeWidth: 1, ), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles(), rightTitles: const AxisTitles(), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, interval: ((maxY - minY) / 4).clamp(0.5, 100), getTitlesWidget: (value, meta) => Text( value.toStringAsFixed(1), style: const TextStyle( fontSize: 10.5, color: Color(0xFFBCAAA4)), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 26, // 最多标 4 个日期,再多会挤成一团 interval: maxX <= 0 ? 1 : (maxX / 3).ceilToDouble(), getTitlesWidget: (value, meta) { final date = base.add(Duration(days: value.round())); return Padding( padding: const EdgeInsets.only(top: 6), child: Text( '${date.month}-${date.day}', style: const TextStyle( fontSize: 10.5, color: Color(0xFFBCAAA4)), ), ); }, ), ), ), lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipColor: (_) => const Color(0xFF5D4037), getTooltipItems: (spots) => spots.map((spot) { final date = base.add(Duration(days: spot.x.round())); return LineTooltipItem( '${date.month}月${date.day}日\n${spot.y.toStringAsFixed(1)} kg', const TextStyle( color: Colors.white, fontSize: 12, height: 1.5), ); }).toList(), ), ), lineBarsData: [ // 实测:实线 + 圆点 LineChartBarData( spots: actual, isCurved: true, curveSmoothness: 0.25, preventCurveOverShooting: true, color: const Color(0xFFC8956E), barWidth: 2.6, isStrokeCapRound: true, dotData: FlDotData( // 点多了就不画圆点,否则整条线会变成一串珠子 show: actual.length <= 20, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter( radius: 3, color: Colors.white, strokeWidth: 2, strokeColor: const Color(0xFFC8956E), ), ), belowBarData: BarAreaData( show: true, color: const Color(0xFFC8956E).withValues(alpha: 0.1), ), ), // 理想:虚线、不带点 —— 视觉上明确区分「这是参照不是实测」 if (ideal.length >= 2) LineChartBarData( spots: ideal, isCurved: false, color: const Color(0xFF4FA98A), barWidth: 1.6, dashArray: const [5, 4], dotData: const FlDotData(show: false), ), ], ), ), ); } /// Y 轴范围:把实测点和理想线都包进来,上下各留 10% 余量。 /// /// 余量是必要的 —— 贴着边框的线看不出趋势,而且圆点会被裁掉半个。 (double, double) _yRange(List actual, List ideal) { final values = [ ...actual.map((s) => s.y), ...ideal.map((s) => s.y), ]; if (values.isEmpty) return (0, 100); var min = values.first; var max = values.first; for (final v in values) { if (v < min) min = v; if (v > max) max = v; } // 所有点都一样重(用户连着记了几天同一个数)时给一个固定跨度, // 否则 maxY == minY 会让图表算出 NaN if ((max - min).abs() < 0.1) { return (min - 1, max + 1); } final pad = (max - min) * 0.1; return (min - pad, max + pad); } }