import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; class TrendPage extends ConsumerStatefulWidget { final String metricType; const TrendPage({super.key, required this.metricType}); @override ConsumerState createState() => _TrendPageState(); } class _TrendPageState extends ConsumerState { int _period = 7; bool _showAllRecords = false; late List> _data; final _chartKey = GlobalKey(); static const _labels = { 'blood_pressure': '血压趋势', 'heart_rate': '心率趋势', 'glucose': '血糖趋势', 'spo2': '血氧趋势', 'weight': '体重趋势', }; static const _units = { 'blood_pressure': 'mmHg', 'heart_rate': 'bpm', 'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg', }; static const _normalRanges = { 'blood_pressure': {'low1': 90, 'high1': 140, 'low2': 60, 'high2': 90}, 'heart_rate': {'low': 60, 'high': 100}, 'glucose': {'low': 3.9, 'high': 6.1}, 'spo2': {'low': 95.0, 'high': 100.0}, 'weight': {'low': 18.5, 'high': 24.9}, // BMI range }; @override void initState() { super.initState(); _data = _generateMockData(); } @override void didUpdateWidget(covariant TrendPage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.metricType != widget.metricType) { setState(() => _data = _generateMockData()); } } String _getUnit() => _units[widget.metricType] ?? ''; bool get _isDualLine => widget.metricType == 'blood_pressure'; String _getStatus(num value, {num? value2}) { if (widget.metricType == 'blood_pressure') { if (value > (_normalRanges['blood_pressure']!['high1'] as num) || (value2 ?? 0) > (_normalRanges['blood_pressure']!['high2'] as num)) { return '偏高'; } if (value < (_normalRanges['blood_pressure']!['low1'] as num) || (value2 ?? 0) < (_normalRanges['blood_pressure']!['low2'] as num)) { return '偏低'; } return '正常'; } final range = _normalRanges[widget.metricType]; if (range != null && range.containsKey('low')) { if (value > (range['high'] as num)) return '偏高'; if (value < (range['low'] as num)) return '偏低'; } return '正常'; } Color _getStatusColor(String status) { switch (status) { case '偏高': return const Color(0xFFE53935); case '偏低': return const Color(0xFFFF9800); default: return const Color(0xFF43A047); } } List> _generateMockData() { final now = DateTime.now(); final data = >[]; final rng = Random(42); // 7天模式全部有数据;30天/90天模式随机约70%的天数有数据 final hasDataChance = _period <= 7 ? 1.0 : 0.7; for (int i = _period - 1; i >= 0; i--) { final date = now.subtract(Duration(days: i)); num? value; num? value2; final measured = rng.nextDouble() < hasDataChance; if (measured) { switch (widget.metricType) { case 'blood_pressure': value = 110 + rng.nextInt(40); value2 = 70 + rng.nextInt(25); break; case 'heart_rate': value = 65 + rng.nextInt(35); break; case 'glucose': value = 4.0 + rng.nextDouble() * 3.0; break; case 'spo2': value = 95 + rng.nextDouble() * 5; break; case 'weight': value = 58 + rng.nextDouble() * 15; break; default: value = 100 + rng.nextInt(50); } } data.add({ 'date': date, 'value': value, if (value2 != null) ...{'value2': value2}, }); } return data; } String _formatDateLabel(DateTime date) { switch (_period) { case 7: const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; return weekdays[date.weekday - 1]; case 30: return '${date.month}/${date.day}'; default: // 90 return '${date.month}月'; } } String _formatDateTime(DateTime dt) { final now = DateTime.now(); final diff = now.difference(dt).inDays; if (diff == 0) return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; if (diff == 1) return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } String _formatValue(num v) { if (v is double && widget.metricType != 'weight' && widget.metricType != 'spo2') { return v.toStringAsFixed(1); } if (widget.metricType == 'spo2') { return v.toStringAsFixed(1); } if (widget.metricType == 'weight') { return v.toStringAsFixed(1); } return v.toInt().toString(); } // ---- Y轴范围计算 ---- ({double min, double max, double step}) _calcYRange() { if (_data.isEmpty) return (min: 0, max: 100, step: 20); final validData = _data.where((e) => e['value'] != null).toList(); if (validData.isEmpty) return (min: 0, max: 100, step: 20); num allMin, allMax; if (_isDualLine) { final values1 = validData.map((e) => e['value'] as num); final validDual = validData.where((e) => e.containsKey('value2') && e['value2'] != null).toList(); final values2 = validDual.map((e) => e['value2'] as num); allMin = min(values1.reduce(min), values2.isEmpty ? values1.reduce(min) : values2.reduce(min)); allMax = max(values1.reduce(max), values2.isEmpty ? values1.reduce(max) : values2.reduce(max)); } else { final values = validData.map((e) => e['value'] as num); allMin = values.reduce(min); allMax = values.reduce(max); } final padding = (allMax - allMin) * 0.15; if (padding == 0) { allMin -= 10; allMax += 10; } else { allMin -= padding; allMax += padding; } var rawMin = allMin.toDouble(); var rawMax = allMax.toDouble(); final range = rawMax - rawMin; double step; if (range <= 1) { step = 0.2; } else if (range <= 10) { step = 2; } else if (range <= 50) { step = 10; } else if (range <= 200) { step = 20; } else { step = 50; } final niceMin = (rawMin / step).floor() * step; final niceMax = (rawMax / step).ceil() * step; return (min: niceMin, max: niceMax, step: step); } Map _calcStats() { final validData = _data.where((e) => e['value'] != null).toList(); if (validData.isEmpty) return {}; final values = validData.map((e) => e['value'] as num).toList(); final maxVal = values.reduce(max); final minVal = values.reduce(min); final avgVal = values.reduce((a, b) => a + b) / values.length; String avgStr; if (_isDualLine) { final validDual = validData.where((e) => e.containsKey('value2') && e['value2'] != null).toList(); if (validDual.isEmpty) { return { 'max': _formatValue(maxVal), 'min': _formatValue(minVal), 'avg': _formatValue(avgVal), 'count': validData.length, }; } final values2 = validDual.map((e) => e['value2'] as num).toList(); final maxVal2 = values2.reduce(max); final minVal2 = values2.reduce(min); final avgVal2 = values2.reduce((a, b) => a + b) / values2.length; avgStr = '${_formatValue(avgVal)} / ${_formatValue(avgVal2)}'; return { 'max': '${_formatValue(maxVal)} / ${_formatValue(maxVal2)}', 'min': '${_formatValue(minVal)} / ${_formatValue(minVal2)}', 'avg': avgStr, 'count': validData.length, }; } return { 'max': _formatValue(maxVal), 'min': _formatValue(minVal), 'avg': _formatValue(avgVal), 'count': validData.length, }; } @override Widget build(BuildContext context) { final title = _labels[widget.metricType] ?? '趋势图表'; return Scaffold( backgroundColor: const Color(0xFFF6F9FB), appBar: AppBar( backgroundColor: Colors.white, elevation: 0, leading: IconButton( icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref), ), title: Text( title, style: const TextStyle( color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600, fontSize: 18, ), ), centerTitle: true, ), body: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), // ---- 时间段选择器 ---- Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ _TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() { _period = 7; _showAllRecords = false; _data = _generateMockData(); })), const SizedBox(width: 12), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() { _period = 30; _showAllRecords = false; _data = _generateMockData(); })), const SizedBox(width: 12), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() { _period = 90; _showAllRecords = false; _data = _generateMockData(); })), ], ), ), const SizedBox(height: 16), // ---- 当前值卡片 ---- ..._buildCurrentValueCard(), const SizedBox(height: 16), // ---- 趋势图表区域 ---- _buildChartArea(), const SizedBox(height: 16), // ---- 统计摘要行 ---- _buildStatSummary(), const SizedBox(height: 16), // ---- 数据记录列表 ---- _buildRecordList(), const SizedBox(height: 32), ], ), ), ); } // ==================== 当前值卡片 ==================== List _buildCurrentValueCard() { if (_data.isEmpty) return []; // 找到最新的一条有数据的记录 final latest = _data.reversed.where((e) => e['value'] != null).firstOrNull; if (latest == null) return []; final val = latest['value']; final val2 = latest['value2']; final status = _getStatus(val as num, value2: val2 as num?); String displayValue; if (_isDualLine) { displayValue = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; } else { displayValue = '${_formatValue(val)} ${_getUnit()}'; } final timeStr = _formatDateTime(latest['date'] as DateTime); return [ Container( width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFFE6FAF6), Color(0xFFF3F1FF)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayValue, style: const TextStyle( fontSize: 26, fontWeight: FontWeight.bold, color: Color(0xFF333333), ), ), const SizedBox(height: 6), Text( timeStr, style: const TextStyle( fontSize: 13, color: Color(0xFF888888), ), ), ], ), ), Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: _getStatusColor(status).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20), ), child: Text( status, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: _getStatusColor(status), ), ), ), ], ), ), ]; } // ==================== 图表区域 ==================== Widget _buildChartArea() { if (_data.isEmpty) { return Container( height: 220, alignment: Alignment.center, child: const Text('暂无数据', style: TextStyle(color: Color(0xFF999999), fontSize: 14)), ); } final yRange = _calcYRange(); return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(12, 16, 8, 8), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: const Color(0xFF14B8A6).withValues(alpha: 0.06), blurRadius: 20, offset: const Offset(0, 4), ), ], ), child: Column( children: [ // 图例 Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildLegendDot(const Color(0xFF14B8A6), _isDualLine ? '收缩压' : _labels[widget.metricType]?.replaceAll('趋势', '') ?? ''), if (_isDualLine) ...[ const SizedBox(width: 24), _buildLegendDot(const Color(0xFF43A047), '舒张压'), ], ], ), const SizedBox(height: 12), GestureDetector( key: _chartKey, onTapDown: (details) => _onChartTap(details, yRange), child: SizedBox( height: 200, child: CustomPaint( painter: _TrendChartPainter( data: _data, metricType: widget.metricType, isDualLine: _isDualLine, yMin: yRange.min, yMax: yRange.max, yStep: yRange.step, formatDateLabel: _formatDateLabel, formatValue: _formatValue, ), size: Size.infinite, ), ), ), ], ), ); } Widget _buildLegendDot(Color color, String label) { return Row(mainAxisSize: MainAxisSize.min, children: [ Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 6), Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), ]); } // ==================== 统计摘要 ==================== Widget _buildStatSummary() { final stats = _calcStats(); if (stats.isEmpty) return const SizedBox.shrink(); 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.04), blurRadius: 12, offset: const Offset(0, 2), ), ], ), child: Row(children: [ Expanded(child: _StatItem(label: '最高值', value: stats['max'].toString(), color: const Color(0xFFE53935))), _buildVerticalDivider(), Expanded(child: _StatItem(label: '最低值', value: stats['min'].toString(), color: const Color(0xFFFF9800))), _buildVerticalDivider(), Expanded(child: _StatItem(label: '平均值', value: stats['avg'].toString(), color: const Color(0xFF14B8A6))), _buildVerticalDivider(), Expanded(child: _StatItem(label: '测量次数', value: stats['count'].toString(), color: const Color(0xFF78909C))), ]), ); } Widget _buildVerticalDivider() => Container(width: 1, height: 36, color: const Color(0xFFF0F0F0)); // ==================== 数据记录列表 ==================== Widget _buildRecordList() { if (_data.isEmpty) return const SizedBox.shrink(); final validRecords = _data.where((e) => e['value'] != null).toList().reversed.toList(); final displayList = _showAllRecords ? validRecords : validRecords.take(5).toList(); return Container( width: double.infinity, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 12, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('数据记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), Text('${validRecords.length} 条', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), ], ), ), ...displayList.map((item) => _buildRecordRow(item)), if (validRecords.length > 5) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Center( child: TextButton( onPressed: () => setState(() => _showAllRecords = !_showAllRecords), style: ButtonStyle( shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), ), child: Text( _showAllRecords ? '收起' : '查看全部 (${validRecords.length} 条)', style: const TextStyle(fontSize: 13, color: Color(0xFF14B8A6)), ), ), ), ), const SizedBox(height: 8), ], ), ); } Widget _buildRecordRow(Map item) { final date = item['date'] as DateTime; final val = item['value'] as num; final val2 = item['value2'] as num?; final status = _getStatus(val, value2: val2); String displayVal; if (_isDualLine) { displayVal = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; } else { displayVal = '${_formatValue(val)} ${_getUnit()}'; } return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ SizedBox( width: 120, child: Text(_formatDateTime(date), style: const TextStyle(fontSize: 13, color: Color(0xFF666666))), ), Expanded( child: Text(displayVal, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), ), Container( width: 8, height: 8, decoration: BoxDecoration(color: _getStatusColor(status), shape: BoxShape.circle), ), ], ), ); } // ==================== 图表点击检测 ==================== void _onChartTap(TapDownDetails details, ({double min, double max, double step}) yRange) { final renderBox = _chartKey.currentContext?.findRenderObject() as RenderBox?; if (renderBox == null || _data.length < 2) return; final localPosition = renderBox.globalToLocal(details.globalPosition); const leftPadding = 44.0; const rightPadding = 8.0; final chartW = renderBox.size.width - leftPadding - rightPadding; // 找到 x 方向最近的有数据点 int? nearestIndex; double minDist = double.infinity; for (int i = 0; i < _data.length; i++) { if (_data[i]['value'] == null) continue; final pointX = leftPadding + (chartW * i / (_data.length - 1)); final dist = (localPosition.dx - pointX).abs(); if (dist < minDist) { minDist = dist; nearestIndex = i; } } // 点击偏离数据点太远或没有有效数据点则不响应 if (nearestIndex == null || minDist > 40) return; final item = _data[nearestIndex]; final date = item['date'] as DateTime; final val = item['value'] as num; final val2 = item['value2'] as num?; final status = _getStatus(val, value2: val2); String displayValue; if (_isDualLine) { displayValue = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; } else { displayValue = '${_formatValue(val)} ${_getUnit()}'; } showDialog( context: context, builder: (ctx) => AlertDialog( title: Text( _formatDateTime(date), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayValue, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Color(0xFF333333)), ), const SizedBox(height: 16), Row( children: [ Container( width: 10, height: 10, decoration: BoxDecoration( color: _getStatusColor(status), shape: BoxShape.circle, ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: _getStatusColor(status).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(12), ), child: Text( status, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: _getStatusColor(status), ), ), ), ], ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('关闭', style: TextStyle(color: Color(0xFF14B8A6))), ), ], ), ); } } // ============================================================ // 子组件 // ============================================================ class _TimeChip extends StatelessWidget { final String label; final bool selected; final VoidCallback onTap; const _TimeChip({required this.label, required this.selected, required this.onTap}); @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), decoration: BoxDecoration( color: selected ? const Color(0xFF14B8A6) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: selected ? const Color(0xFF14B8A6) : const Color(0xFFE0E0E0)), boxShadow: selected ? [BoxShadow(color: const Color(0xFF14B8A6).withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 3))] : null, ), child: Text( label, style: TextStyle( fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, color: selected ? Colors.white : const Color(0xFF757575), ), ), ), ); } class _StatItem extends StatelessWidget { final String label; final String value; final Color color; const _StatItem({required this.label, required this.value, required this.color}); @override Widget build(BuildContext context) => Column( children: [ Row(mainAxisSize: MainAxisSize.min, children: [ Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 6), Flexible(child: Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: color), overflow: TextOverflow.ellipsis)), ]), const SizedBox(height: 4), Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), ], ); } // ============================================================ // 趋势图 CustomPainter // ============================================================ class _Segment { final int start; final int end; const _Segment({required this.start, required this.end}); } class _TrendChartPainter extends CustomPainter { final List> data; final String metricType; final bool isDualLine; final double yMin; final double yMax; final double yStep; final String Function(DateTime) formatDateLabel; final String Function(num) formatValue; static const _primaryColor = Color(0xFF14B8A6); static const _secondaryColor = Color(0xFF43A047); static const _gridColor = Color(0xFFEEEEEE); static const _labelColor = Color(0xFF999999); _TrendChartPainter({ required this.data, required this.metricType, required this.isDualLine, required this.yMin, required this.yMax, required this.yStep, required this.formatDateLabel, required this.formatValue, }); @override void paint(Canvas canvas, Size size) { if (data.length < 2) return; final leftPadding = 44.0; final rightPadding = 8.0; final topPadding = 8.0; final bottomPadding = 28.0; final chartW = size.width - leftPadding - rightPadding; final chartH = size.height - topPadding - bottomPadding; final plotLeft = leftPadding; final plotTop = topPadding; final plotRight = size.width - rightPadding; final plotBottom = size.height - bottomPadding; // ---- 1. 网格线 & Y轴刻度 ---- _drawGridAndYAxis(canvas, plotLeft, plotTop, plotRight, plotBottom, chartH); // ---- 2. 数据点坐标计算(跳过 null 值)---- final points1 = []; final points2 = []; // 记录每条线的分段信息:每个段是连续非null的点的索引范围 final segments1 = <_Segment>[]; final segments2 = <_Segment>[]; int? segStart1; int? segStart2; for (int i = 0; i < data.length; i++) { final x = plotLeft + (chartW * i / (data.length - 1)); final val = data[i]['value'] as num?; if (val != null) { final y = plotTop + chartH - ((val.toDouble() - yMin) / (yMax - yMin)) * chartH; points1.add(Offset(x, y.clamp(plotTop, plotBottom))); segStart1 ??= points1.length - 1; } else { if (segStart1 != null && points1.length > segStart1 + 1) { segments1.add(_Segment(start: segStart1, end: points1.length - 1)); } segStart1 = null; } if (isDualLine && data[i].containsKey('value2')) { final val2 = data[i]['value2'] as num?; if (val2 != null) { final y2 = plotTop + chartH - ((val2.toDouble() - yMin) / (yMax - yMin)) * chartH; points2.add(Offset(x, y2.clamp(plotTop, plotBottom))); segStart2 ??= points2.length - 1; } else { if (segStart2 != null && points2.length > segStart2 + 1) { segments2.add(_Segment(start: segStart2, end: points2.length - 1)); } segStart2 = null; } } } // 收尾最后一个段 if (segStart1 != null && points1.length > segStart1 + 1) { segments1.add(_Segment(start: segStart1, end: points1.length - 1)); } if (segStart2 != null && points2.length > segStart2 + 1) { segments2.add(_Segment(start: segStart2, end: points2.length - 1)); } // ---- 3. 填充区域(主线,分段绘制)---- if (segments1.isNotEmpty) { for (final seg in segments1) { _drawFill(canvas, points1.sublist(seg.start, seg.end + 1), plotBottom, _primaryColor); } } else if (points1.length >= 2) { _drawFill(canvas, points1, plotBottom, _primaryColor); } // ---- 4. 填充区域(副线,血压,分段绘制)---- if (isDualLine) { if (segments2.isNotEmpty) { for (final seg in segments2) { _drawFill(canvas, points2.sublist(seg.start, seg.end + 1), plotBottom, _secondaryColor); } } else if (points2.length >= 2) { _drawFill(canvas, points2, plotBottom, _secondaryColor); } } // ---- 5. 数据线(主线,分段绘制)---- if (segments1.isNotEmpty) { for (final seg in segments1) { _drawLine(canvas, points1.sublist(seg.start, seg.end + 1), _primaryColor); } } else if (points1.length >= 2) { _drawLine(canvas, points1, _primaryColor); } // ---- 6. 数据线(副线,分段绘制)---- if (isDualLine) { if (segments2.isNotEmpty) { for (final seg in segments2) { _drawLine(canvas, points2.sublist(seg.start, seg.end + 1), _secondaryColor); } } else if (points2.length >= 2) { _drawLine(canvas, points2, _secondaryColor); } } // ---- 7. 数据点 ---- _drawPoints(canvas, points1, _primaryColor); if (isDualLine && points2.isNotEmpty) { _drawPoints(canvas, points2, _secondaryColor); } // ---- 8. X轴日期标签 ---- _drawXLabels(canvas, plotLeft, plotRight, plotBottom, chartW); } void _drawGridAndYAxis(Canvas canvas, double plotLeft, double plotTop, double plotRight, double plotBottom, double chartH) { final dashPaint = Paint() ..color = _gridColor ..strokeWidth = 0.8 ..style = PaintingStyle.stroke; final labelStyle = TextStyle(color: _labelColor, fontSize: 11); final stepsCount = ((yMax - yMin) / yStep).round(); for (int i = 0; i <= stepsCount; i++) { final yVal = yMin + yStep * i; if (yVal > yMax + 0.001) break; final y = plotTop + chartH - ((yVal - yMin) / (yMax - yMin)) * chartH; // 虚线网格 final dashPath = Path(); const dashLen = 4.0; const gapLen = 3.0; var startX = plotLeft; while (startX < plotRight) { final endX = (startX + dashLen).clamp(startX, plotRight); dashPath.moveTo(startX, y); dashPath.lineTo(endX, y); startX = endX + gapLen; } canvas.drawPath(dashPath, dashPaint); // Y轴标签 final textSpan = TextSpan(text: formatValue(yVal), style: labelStyle); final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout(); tp.paint(canvas, Offset(plotLeft - tp.width - 6, y - tp.height / 2)); } } void _drawFill(Canvas canvas, List points, double plotBottom, Color lineColor) { if (points.length < 2) return; final fillPaint = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [lineColor.withValues(alpha: 0.25), lineColor.withValues(alpha: 0.02)], ).createShader(Rect.fromLTWH(0, 0, points.last.dx - points.first.dx, plotBottom - points.first.dy)) ..style = PaintingStyle.fill; final fillPath = Path() ..moveTo(points.first.dx, plotBottom) ..lineTo(points.first.dx, points.first.dy); for (int i = 1; i < points.length; i++) { fillPath.lineTo(points[i].dx, points[i].dy); } fillPath.lineTo(points.last.dx, plotBottom); fillPath.close(); canvas.drawPath(fillPath, fillPaint); } void _drawLine(Canvas canvas, List points, Color color) { if (points.length < 2) return; final paint = Paint() ..color = color ..strokeWidth = 2.5 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; final path = Path()..moveTo(points.first.dx, points.first.dy); for (int i = 1; i < points.length; i++) { path.lineTo(points[i].dx, points[i].dy); } canvas.drawPath(path, paint); } void _drawPoints(Canvas canvas, List points, Color color) { final whitePaint = Paint()..color = Colors.white..style = PaintingStyle.fill; final strokePaint = Paint() ..color = color ..strokeWidth = 2.0 ..style = PaintingStyle.stroke; for (final p in points) { canvas.drawCircle(p, 5, whitePaint); canvas.drawCircle(p, 4, strokePaint); } } void _drawXLabels(Canvas canvas, double plotLeft, double plotRight, double plotBottom, double chartW) { final labelStyle = TextStyle(color: _labelColor, fontSize: 11); final count = data.length; final maxLabels = count <= 7 ? count : (count ~/ 7).clamp(3, 12); final step = (count - 1) / (maxLabels - 1); for (int i = 0; i < maxLabels; i++) { final idx = (i * step).round().clamp(0, count - 1); final x = plotLeft + (chartW * idx / (count - 1)); final date = data[idx]['date'] as DateTime; final textSpan = TextSpan(text: formatDateLabel(date), style: labelStyle); final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout(); tp.paint(canvas, Offset(x - tp.width / 2, plotBottom + 6)); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { if (oldDelegate is! _TrendChartPainter) return true; return oldDelegate.data != data || oldDelegate.metricType != metricType || oldDelegate.isDualLine != isDualLine || (oldDelegate.yMin - yMin).abs() > 0.001 || (oldDelegate.yMax - yMax).abs() > 0.001 || (oldDelegate.yStep - yStep).abs() > 0.001; } }