feat: 聊天卡片升级+趋势图重写+智能体欢迎卡片

- AgentWelcomeCard:紫色渐变头部+快捷按钮网格+智能体描述
- DataConfirmCard:绿色渐变确认条+迷你趋势图+编辑/确认按钮
- MedicationConfirmCard:药丸图标+剩余药量进度条+确认/跳过
- DietAnalysisCard:大号热量+营养素圆环+食物明细+AI建议
- ReportAnalysisCard:指标表格+异常高亮+AI解读
- trend_page 重写:CustomPaint 平滑曲线+当前值卡片+统计摘要
- chat_provider 新增 agentWelcome 消息类型
This commit is contained in:
MingNian
2026-06-03 14:25:48 +08:00
parent 36ad334643
commit 9fb60cb3cf
3 changed files with 1842 additions and 374 deletions

View File

@@ -1,87 +1,833 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
class TrendPage extends ConsumerStatefulWidget { class TrendPage extends ConsumerStatefulWidget {
final String metricType; final String metricType;
const TrendPage({super.key, required this.metricType}); const TrendPage({super.key, required this.metricType});
@override ConsumerState<TrendPage> createState() => _TrendPageState();
@override
ConsumerState<TrendPage> createState() => _TrendPageState();
} }
class _TrendPageState extends ConsumerState<TrendPage> { class _TrendPageState extends ConsumerState<TrendPage> {
int _period = 7; int _period = 7;
bool _showAllRecords = false;
late List<Map<String, dynamic>> _data;
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);
for (int i = _period - 1; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
num value;
num? value2;
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);
num allMin, allMax;
if (_isDualLine) {
final values1 = _data.map((e) => e['value'] as num);
final values2 = _data.map((e) => e['value2'] as num);
allMin = min(values1.reduce(min), values2.reduce(min));
allMax = max(values1.reduce(max), values2.reduce(max));
} else {
final values = _data.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() {
if (_data.isEmpty) return {};
final values = _data.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 values2 = _data.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': _data.length,
};
}
return {
'max': _formatValue(maxVal),
'min': _formatValue(minVal),
'avg': _formatValue(avgVal),
'count': _data.length,
};
}
@override
Widget build(BuildContext context) {
final title = _labels[widget.metricType] ?? '趋势图表';
@override Widget build(BuildContext context) {
final labels = {'blood_pressure': '血压趋势', 'heart_rate': '心率趋势', 'glucose': '血糖趋势', 'spo2': '血氧趋势', 'weight': '体重趋势'};
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: const Color(0xFFF8F7FF),
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: Text(labels[widget.metricType] ?? '趋势图表', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), centerTitle: true), appBar: AppBar(
body: Column(children: [ backgroundColor: Colors.white,
Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ elevation: 0,
_TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() => _period = 7)), leading: IconButton(
const SizedBox(width: 12), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() => _period = 30)), icon: const Icon(Icons.chevron_left),
const SizedBox(width: 12), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() => _period = 90)), onPressed: () => popRoute(ref),
])), ),
Container(margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: const Color(0xFFF8F9FF), borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFFE8E6FF))), child: Column(children: [ title: Text(
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(widget.metricType == 'blood_pressure' ? '血压趋势' : labels[widget.metricType] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), Row(children: [Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF635BFF), shape: BoxShape.circle)), const SizedBox(width: 4), Text('收缩压', style: TextStyle(fontSize: 12, color: Colors.grey[600])), const SizedBox(width: 16), Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF43A047), shape: BoxShape.circle)), const SizedBox(width: 4), Text('舒张压', style: TextStyle(fontSize: 12, color: Colors.grey[600]))])]), title,
const SizedBox(height: 24), style: const TextStyle(
SizedBox(height: 200, child: CustomPaint(painter: _LineChartPainter(period: _period), size: Size.infinite)), color: Color(0xFF1A1A1A),
])), fontWeight: FontWeight.w600,
if (widget.metricType == 'blood_pressure') Container(margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFEEEEEE))), child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [const _StatItem(label: '最高', value: '145', unit: '', color: Color(0xFFE53935)), const _StatItem(label: '最低', value: '78', unit: '', color: Color(0xFF43A047)), const _StatItem(label: '平均', value: '120', unit: '/80', color: Color(0xFF635BFF))])), 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.last;
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(0xFFEDEBFF), 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(0xFF635BFF).withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
// 图例
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegendDot(const Color(0xFF635BFF), _isDualLine ? '收缩压' : _labels[widget.metricType]?.replaceAll('趋势', '') ?? ''),
if (_isDualLine) ...[
const SizedBox(width: 24),
_buildLegendDot(const Color(0xFF43A047), '舒张压'),
],
],
),
const SizedBox(height: 12),
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(0xFF635BFF))),
_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 displayList = _showAllRecords ? _data : _data.reversed.take(5).toList().reversed.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('${_data.length}', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
],
),
),
...displayList.map((item) => _buildRecordRow(item)),
if (_data.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 ? '收起' : '查看全部 (${_data.length} 条)',
style: const TextStyle(fontSize: 13, color: Color(0xFF635BFF)),
),
),
),
),
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),
),
],
),
);
}
} }
// ============================================================
// 子组件
// ============================================================
class _TimeChip extends StatelessWidget { class _TimeChip extends StatelessWidget {
final String label; final bool selected; final VoidCallback onTap; final String label;
final bool selected;
final VoidCallback onTap;
const _TimeChip({required this.label, required this.selected, required this.onTap}); const _TimeChip({required this.label, required this.selected, required this.onTap});
@override Widget build(BuildContext context) => GestureDetector( @override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap, onTap: onTap,
child: Container(padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), decoration: BoxDecoration(color: selected ? const Color(0xFF635BFF) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: selected ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0))), child: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, color: selected ? Colors.white : const Color(0xFF757575)))), child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8),
decoration: BoxDecoration(
color: selected ? const Color(0xFF635BFF) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: selected ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)),
boxShadow: selected ? [BoxShadow(color: const Color(0xFF635BFF).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 String unit; final Color color; class _StatItem extends StatelessWidget {
const _StatItem({required this.label, required this.value, required this.unit, required this.color}); final String label;
@override Widget build(BuildContext context) => Column(children: [Text(value + unit, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)), const SizedBox(height: 4), Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[500]))]); 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))),
],
);
} }
class _LineChartPainter extends CustomPainter { // ============================================================
final int period; // 趋势图 CustomPainter
_LineChartPainter({required this.period}); // ============================================================
@override void paint(Canvas canvas, Size size) { class _TrendChartPainter extends CustomPainter {
final paint = Paint()..color = const Color(0xFF635BFF)..strokeWidth = 2..style = PaintingStyle.stroke; final List<Map<String, dynamic>> data;
final paint2 = Paint()..color = const Color(0xFF43A047)..strokeWidth = 2..style = PaintingStyle.stroke; final String metricType;
final fillPaint1 = Paint()..color = const Color(0xFF635BFF)..style = PaintingStyle.fill; final bool isDualLine;
final fillPaint2 = Paint()..color = const Color(0xFF43A047)..style = PaintingStyle.fill; final double yMin;
final whitePaint = Paint()..color = Colors.white..style = PaintingStyle.fill; final double yMax;
final double yStep;
final String Function(DateTime) formatDateLabel;
final String Function(num) formatValue;
final points1 = <Offset>[]; static const _primaryColor = Color(0xFF635BFF);
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. 数据点坐标计算 ----
final points1 = <Offset>[];
final points2 = <Offset>[]; final points2 = <Offset>[];
for (int i = 0; i < data.length; i++) {
if (period <= 1) return; final x = plotLeft + (chartW * i / (data.length - 1));
final val = (data[i]['value'] as num).toDouble();
for (int i = 0; i < period; i++) { final y = plotTop + chartH - ((val - yMin) / (yMax - yMin)) * chartH;
final x = size.width * i / (period - 1); points1.add(Offset(x, y.clamp(plotTop, plotBottom)));
points1.add(Offset(x, size.height * 0.3 + (i % 3) * 15));
points2.add(Offset(x, size.height * 0.6 + (i % 4) * 10)); if (isDualLine && data[i].containsKey('value2')) {
final val2 = (data[i]['value2'] as num).toDouble();
final y2 = plotTop + chartH - ((val2 - yMin) / (yMax - yMin)) * chartH;
points2.add(Offset(x, y2.clamp(plotTop, plotBottom)));
}
} }
if (points1.length > 1) { // ---- 3. 填充区域(主线) ----
final path1 = Path()..moveTo(points1[0].dx, points1[0].dy); _drawFill(canvas, points1, plotBottom, _primaryColor);
for (var p in points1.skip(1)) path1.lineTo(p.dx, p.dy);
canvas.drawPath(path1, paint);
final path2 = Path()..moveTo(points2[0].dx, points2[0].dy); // ---- 4. 填充区域(副线,血压) ----
for (var p in points2.skip(1)) path2.lineTo(p.dx, p.dy); if (isDualLine && points2.isNotEmpty) {
canvas.drawPath(path2, paint2); _drawFill(canvas, points2, plotBottom, _secondaryColor);
} }
for (var p in points1) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint1); } // ---- 5. 数据线(主线) ----
for (var p in points2) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint2); } _drawSmoothLine(canvas, points1, _primaryColor);
// ---- 6. 数据线(副线) ----
if (isDualLine && points2.isNotEmpty) {
_drawSmoothLine(canvas, points2, _secondaryColor);
}
// ---- 7. 数据点 ----
_drawPoints(canvas, points1, _primaryColor);
if (isDualLine && points2.isNotEmpty) {
_drawPoints(canvas, points2, _secondaryColor);
}
// ---- 8. X轴日期标签 ----
_drawXLabels(canvas, plotLeft, plotRight, plotBottom, chartW);
} }
@override bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate is! _LineChartPainter || oldDelegate.period != period; 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++) {
final midX = (points[i - 1].dx + points[i].dx) / 2;
fillPath.quadraticBezierTo(midX, points[i - 1].dy, midX, (points[i - 1].dy + points[i].dy) / 2);
}
fillPath.lineTo(points.last.dx, points.last.dy);
fillPath.lineTo(points.last.dx, plotBottom);
fillPath.close();
canvas.drawPath(fillPath, fillPaint);
}
void _drawSmoothLine(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[0].dx, points[0].dy);
for (int i = 1; i < points.length; i++) {
final cpx = (points[i - 1].dx + points[i].dx) / 2;
path.quadraticBezierTo(cpx, points[i - 1].dy, (cpx + points[i].dx) / 2, (points[i - 1].dy + points[i].dy) / 2);
}
path.lineTo(points.last.dx, points.last.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;
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import 'auth_provider.dart';
import 'data_providers.dart'; import 'data_providers.dart';
import '../utils/sse_handler.dart'; import '../utils/sse_handler.dart';
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions } enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome }
class ChatMessage { class ChatMessage {
final String id; final String id;
@@ -236,6 +236,7 @@ class ChatNotifier extends Notifier<ChatState> {
case 'diet_analysis': return MessageType.dietAnalysis; case 'diet_analysis': return MessageType.dietAnalysis;
case 'report_analysis': return MessageType.reportAnalysis; case 'report_analysis': return MessageType.reportAnalysis;
case 'quick_options': return MessageType.quickOptions; case 'quick_options': return MessageType.quickOptions;
case 'agent_welcome': return MessageType.agentWelcome;
default: return MessageType.text; default: return MessageType.text;
} }
} }