- 主色 #635BFF→#14B8A6 (薄荷绿) - 浅紫 #EDEBFF→#E6FAF6 (极浅薄荷) - 深紫 #4B44D6→#0F9D8E (深薄荷) - 渐变紫→薄荷渐变 - 全局13种紫色映射替换
1016 lines
34 KiB
Dart
1016 lines
34 KiB
Dart
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(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<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(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<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(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<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(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 = <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;
|
||
}
|
||
}
|