Files
AI-Health/health_app/lib/pages/chart/trend_page.dart
MingNian f6c1ea7ec9 style: 淡薰紫 Lavender Breeze + UI修复
- 主色改淡薰紫 #8B9CF7 + 白底 清新风格
- 每个智能体卡不同淡色调
- 删除欢迎卡底部"或直接对我说"
- 运动创建/打卡接入 API
- 全项目薄荷绿→淡紫替换
2026-06-03 20:46:39 +08:00

1016 lines
34 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TrendPage> createState() => _TrendPageState();
}
class _TrendPageState extends ConsumerState<TrendPage> {
int _period = 7;
bool _showAllRecords = false;
late List<Map<String, dynamic>> _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<Map<String, dynamic>> _generateMockData() {
final now = DateTime.now();
final data = <Map<String, dynamic>>[];
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<String, dynamic> _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(0xFFF8F9FC),
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<Widget> _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(0xFFF0F2FF), 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(0xFF8B9CF7).withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
// 图例
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendDot(const Color(0xFF8B9CF7), _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(0xFF8B9CF7))),
_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(0xFF8B9CF7)),
),
),
),
),
const SizedBox(height: 8),
],
),
);
}
Widget _buildRecordRow(Map<String, dynamic> 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(0xFF8B9CF7))),
),
],
),
);
}
}
// ============================================================
// 子组件
// ============================================================
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(0xFF8B9CF7) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: selected ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
boxShadow: selected ? [BoxShadow(color: const Color(0xFF8B9CF7).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<Map<String, dynamic>> 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(0xFF8B9CF7);
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 = <Offset>[];
final points2 = <Offset>[];
// 记录每条线的分段信息每个段是连续非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<Offset> 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<Offset> 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<Offset> 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;
}
}