fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒
- SignalR Hub消息持久化+防重复+跨端广播 - 报告VLM+LLM真实AI流程(验证→提取→解读) - 医生审核页重构(严重程度/建议模板/综合评语) - 健康概览五合一趋势图(fl_chart+指标切换) - 用药提醒时区修复+跨午夜+过期提醒 - 运动打卡DayOfWeek映射修复+计划覆盖查询 - 体重与其他四指标同级(Abnormal检查/确认卡牌) - AI Agent支持多种类多时段批量录入 - 删除硬编码假数据(张三/假医生/假AI解读) - 随访/报告审核数据链路全部打通
This commit is contained in:
@@ -1,254 +1,392 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
/// 健康概览趋势页 — 五大指标合到一个页面
|
||||
class TrendPage extends ConsumerStatefulWidget {
|
||||
final String metricType;
|
||||
const TrendPage({super.key, required this.metricType});
|
||||
final String? metricType; // 可选初始选中指标
|
||||
const TrendPage({super.key, this.metricType});
|
||||
|
||||
@override ConsumerState<TrendPage> createState() => _TrendPageState();
|
||||
}
|
||||
|
||||
class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
int _period = 7;
|
||||
List<Map<String, dynamic>> _records = [];
|
||||
String _selected = 'blood_pressure';
|
||||
List<Map<String, dynamic>> _allRecords = [];
|
||||
List<Map<String, dynamic>> _filtered = [];
|
||||
bool _loading = true;
|
||||
|
||||
final _value1Ctrl = TextEditingController();
|
||||
final _value2Ctrl = TextEditingController();
|
||||
static const _metrics = [
|
||||
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
|
||||
{'key': 'heart_rate', 'label': '心率', 'color': Color(0xFFF59E0B)},
|
||||
{'key': 'glucose', 'label': '血糖', 'color': Color(0xFF4F6EF7)},
|
||||
{'key': 'spo2', 'label': '血氧', 'color': Color(0xFF20C997)},
|
||||
{'key': 'weight', 'label': '体重', 'color': Color(0xFF845EF7)},
|
||||
];
|
||||
|
||||
static const _titles = {
|
||||
'blood_pressure': '血压', 'heart_rate': '心率',
|
||||
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
|
||||
};
|
||||
static const _units = {
|
||||
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
|
||||
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
|
||||
};
|
||||
|
||||
String get _title => _titles[widget.metricType] ?? '';
|
||||
String get _unit => _units[widget.metricType] ?? '';
|
||||
bool get _isBP => widget.metricType == 'blood_pressure';
|
||||
static const _names = {
|
||||
'blood_pressure': '血压', 'heart_rate': '心率',
|
||||
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
|
||||
};
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
@override void dispose() { _value1Ctrl.dispose(); _value2Ctrl.dispose(); super.dispose(); }
|
||||
String get _unit => _units[_selected] ?? '';
|
||||
String get _name => _names[_selected] ?? '';
|
||||
bool get _isBP => _selected == 'blood_pressure';
|
||||
Color get _color => _metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
||||
|
||||
Future<void> _load() async {
|
||||
@override void initState() {
|
||||
super.initState();
|
||||
if (widget.metricType != null) _selected = widget.metricType!;
|
||||
_loadAll();
|
||||
}
|
||||
|
||||
Future<void> _loadAll() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/health-records/trend', queryParameters: {'type': widget.metricType, 'period': _period});
|
||||
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final data = list.map((r) {
|
||||
final v = r['value'];
|
||||
return {
|
||||
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
'value': _isBP ? r['systolic'] : (v is num ? v : null),
|
||||
if (_isBP) 'value2': r['diastolic'],
|
||||
'isAbnormal': r['isAbnormal'] == true,
|
||||
};
|
||||
}).toList();
|
||||
if (mounted) setState(() { _records = data; _loading = false; });
|
||||
final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight'];
|
||||
final all = <Map<String, dynamic>>[];
|
||||
for (final t in types) {
|
||||
try {
|
||||
final res = await api.get('/api/health-records/trend',
|
||||
queryParameters: {'type': t, 'period': 365});
|
||||
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
for (final r in list) {
|
||||
all.add({
|
||||
'type': t,
|
||||
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
'systolic': r['systolic'],
|
||||
'diastolic': r['diastolic'],
|
||||
'value': r['value'],
|
||||
'unit': r['unit'] ?? '',
|
||||
'isAbnormal': r['isAbnormal'] == true,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
|
||||
if (mounted) {
|
||||
setState(() { _allRecords = all; _loading = false; });
|
||||
_filter();
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addRecord() async {
|
||||
final v1 = double.tryParse(_value1Ctrl.text.trim());
|
||||
if (v1 == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入数值')));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final body = <String, dynamic>{
|
||||
'type': widget.metricType == 'blood_pressure' ? 'BloodPressure'
|
||||
: widget.metricType == 'heart_rate' ? 'HeartRate'
|
||||
: widget.metricType == 'glucose' ? 'Glucose'
|
||||
: widget.metricType == 'spo2' ? 'SpO2'
|
||||
: 'Weight',
|
||||
'source': 'Manual',
|
||||
'recordedAt': DateTime.now().toIso8601String(),
|
||||
'unit': _unit,
|
||||
};
|
||||
if (_isBP) {
|
||||
body['systolic'] = v1.toInt();
|
||||
final v2 = double.tryParse(_value2Ctrl.text.trim());
|
||||
body['diastolic'] = v2?.toInt() ?? 0;
|
||||
} else {
|
||||
body['value'] = v1;
|
||||
}
|
||||
await api.post('/api/health-records', data: body);
|
||||
_value1Ctrl.clear();
|
||||
_value2Ctrl.clear();
|
||||
ref.invalidate(latestHealthProvider);
|
||||
_load();
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录'), backgroundColor: Color(0xFF43A047)));
|
||||
} catch (_) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('记录失败'), backgroundColor: Color(0xFFE53935)));
|
||||
}
|
||||
void _filter() {
|
||||
final typeName = _selected == 'blood_pressure' ? 'BloodPressure'
|
||||
: _selected == 'heart_rate' ? 'HeartRate'
|
||||
: _selected == 'glucose' ? 'Glucose'
|
||||
: _selected == 'spo2' ? 'SpO2'
|
||||
: 'Weight';
|
||||
setState(() {
|
||||
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
|
||||
_filtered.sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
|
||||
});
|
||||
}
|
||||
|
||||
String _statusText(Map<String, dynamic> r) {
|
||||
if (r['isAbnormal'] != true) return '正常';
|
||||
if (_isBP) {
|
||||
final s = r['value'] as int? ?? 0;
|
||||
final d = r['value2'] as int? ?? 0;
|
||||
if (s >= 140 || d >= 90) return '偏高';
|
||||
if (s <= 89 || d <= 59) return '偏低';
|
||||
} else {
|
||||
final v = r['value'] as num? ?? 0;
|
||||
final ranges = {'heart_rate': [60, 100], 'glucose': [3.9, 6.1], 'spo2': [95, 100], 'weight': [18.5, 24.9]};
|
||||
final range = ranges[widget.metricType];
|
||||
if (range != null) {
|
||||
if (v > range[1]) return '偏高';
|
||||
if (v < range[0]) return '偏低';
|
||||
}
|
||||
}
|
||||
return '异常';
|
||||
void _switchMetric(String key) {
|
||||
setState(() => _selected = key);
|
||||
_filter();
|
||||
}
|
||||
|
||||
Color _statusColor(String s) {
|
||||
switch (s) {
|
||||
case '偏高': return const Color(0xFFE53935);
|
||||
case '偏低': return const Color(0xFFFF9800);
|
||||
default: return const Color(0xFF43A047);
|
||||
}
|
||||
// ---- 手动录入 ----
|
||||
void _showAddDialog() {
|
||||
final ctrl1 = TextEditingController();
|
||||
final ctrl2 = TextEditingController();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom, left: 20, right: 20, top: 20),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('录入$_name', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 16),
|
||||
if (_isBP) ...[
|
||||
Row(children: [
|
||||
Expanded(child: TextField(controller: ctrl1, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '收缩压 (mmHg)', border: OutlineInputBorder()))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: TextField(controller: ctrl2, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '舒张压 (mmHg)', border: OutlineInputBorder()))),
|
||||
]),
|
||||
] else
|
||||
TextField(controller: ctrl1, keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(labelText: '$_name ($_unit)', border: const OutlineInputBorder())),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final v1 = double.tryParse(ctrl1.text.trim());
|
||||
if (v1 == null) return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final body = <String, dynamic>{
|
||||
'type': _selected == 'blood_pressure' ? 'BloodPressure'
|
||||
: _selected == 'heart_rate' ? 'HeartRate'
|
||||
: _selected == 'glucose' ? 'Glucose'
|
||||
: _selected == 'spo2' ? 'SpO2'
|
||||
: 'Weight',
|
||||
'source': 'Manual',
|
||||
'recordedAt': DateTime.now().toIso8601String(),
|
||||
'unit': _unit,
|
||||
};
|
||||
if (_isBP) {
|
||||
body['systolic'] = v1.toInt();
|
||||
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
|
||||
} else {
|
||||
body['value'] = v1;
|
||||
}
|
||||
await api.post('/api/health-records', data: body);
|
||||
Navigator.pop(ctx);
|
||||
ref.invalidate(latestHealthProvider);
|
||||
_loadAll();
|
||||
} catch (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('录入失败')));
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: _color, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: const Text('确认录入', style: TextStyle(fontSize: 16)),
|
||||
)),
|
||||
const SizedBox(height: 20),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(title: Text('$_title趋势'), centerTitle: true),
|
||||
body: _loading ? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
|
||||
_buildChartSection(),
|
||||
const SizedBox(height: 20),
|
||||
_buildManualEntry(),
|
||||
const SizedBox(height: 20),
|
||||
_buildRecordList(),
|
||||
const SizedBox(height: 40),
|
||||
])),
|
||||
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddDialog,
|
||||
backgroundColor: _color,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadAll,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(children: [
|
||||
_buildMetricTabs(),
|
||||
const SizedBox(height: 16),
|
||||
_buildChart(),
|
||||
const SizedBox(height: 20),
|
||||
_buildHistory(),
|
||||
const SizedBox(height: 80),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartSection() {
|
||||
final valid = _records.where((r) => r['value'] != null).toList();
|
||||
if (valid.isEmpty) {
|
||||
// ---- 指标选择标签 ----
|
||||
Widget _buildMetricTabs() {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(children: _metrics.map((m) {
|
||||
final key = m['key'] as String;
|
||||
final color = m['color'] as Color;
|
||||
final sel = _selected == key;
|
||||
return GestureDetector(
|
||||
onTap: () => _switchMetric(key),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? color : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: sel ? color : const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: Text(m['label'] as String, style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600,
|
||||
color: sel ? Colors.white : const Color(0xFF666666)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 趋势图 ----
|
||||
Widget _buildChart() {
|
||||
if (_filtered.isEmpty) {
|
||||
return Container(
|
||||
height: 200, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
child: const Center(child: Text('暂无数据,请在下方手动录入', style: TextStyle(color: Color(0xFFBBBBBB)))),
|
||||
height: 220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.show_chart, size: 48, color: Colors.grey[300]),
|
||||
const SizedBox(height: 8),
|
||||
Text('暂无$_name数据', style: const TextStyle(color: Color(0xFFBBBBBB))),
|
||||
const SizedBox(height: 4),
|
||||
const Text('点击右下角 + 手动录入', style: TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
|
||||
])),
|
||||
);
|
||||
}
|
||||
final values = valid.map((r) => (r['value'] as num).toDouble()).toList();
|
||||
final maxVal = values.reduce((a, b) => a > b ? a : b);
|
||||
final minVal = values.reduce((a, b) => a < b ? a : b);
|
||||
final range = maxVal - minVal;
|
||||
final normalized = values.map((v) => range > 0 ? (v - minVal) / range : 0.5).toList();
|
||||
|
||||
// Time chips
|
||||
final spots = <FlSpot>[];
|
||||
double minV = double.infinity, maxV = double.negativeInfinity;
|
||||
for (int i = 0; i < _filtered.length; i++) {
|
||||
final r = _filtered[i];
|
||||
final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble();
|
||||
if (v == null) continue;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
spots.add(FlSpot(i.toDouble(), v));
|
||||
}
|
||||
|
||||
if (spots.isEmpty) return const SizedBox.shrink();
|
||||
final range = maxV - minV;
|
||||
final padding = range * 0.15;
|
||||
if (padding < 1) { minV -= 5; maxV += 5; }
|
||||
else { minV -= padding; maxV += padding; }
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Column(children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
_chip('7天', 7), _chip('30天', 30), _chip('90天', 90),
|
||||
Row(children: [
|
||||
const SizedBox(width: 8),
|
||||
Text(_name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: _color)),
|
||||
const SizedBox(width: 6),
|
||||
Text('趋势 (${_filtered.length}条)', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
const Spacer(),
|
||||
Text('单位: $_unit', style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 160, child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(normalized.length, (i) {
|
||||
final h = normalized[i] * 140 + 10;
|
||||
return Expanded(child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: i < normalized.length - 1 ? 2 : 0),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
Container(height: h, decoration: BoxDecoration(
|
||||
color: valid[i]['isAbnormal'] == true ? const Color(0xFFF56C6C) : const Color(0xFF6C5CE7),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: (spots.length - 1).toDouble(),
|
||||
minY: minV,
|
||||
maxY: maxV,
|
||||
gridData: FlGridData(show: true, drawVerticalLine: false,
|
||||
horizontalInterval: range > 50 ? 10 : range > 20 ? 5 : range > 5 ? 2 : 1),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 40,
|
||||
getTitlesWidget: (v, meta) => Text(v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFF999999))),
|
||||
)),
|
||||
]),
|
||||
));
|
||||
}),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
Text('最近${valid.length}条记录 · $_unit', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(String label, int p) {
|
||||
final sel = _period == p;
|
||||
return GestureDetector(
|
||||
onTap: () { _period = p; _load(); },
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildManualEntry() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('手动录入', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_isBP)
|
||||
Row(children: [
|
||||
Expanded(child: TextField(controller: _value1Ctrl, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(hintText: '收缩压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: TextField(controller: _value2Ctrl, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(hintText: '舒张压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
|
||||
])
|
||||
else
|
||||
TextField(controller: _value1Ctrl, keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(hintText: '$_title ($_unit)', filled: true, fillColor: const Color(0xFFF4F5FA), border: InputBorder.none)),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _addRecord,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
||||
child: const Text('记录')),
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 24,
|
||||
interval: spots.length > 30 ? 7 : spots.length > 14 ? 3 : 1,
|
||||
getTitlesWidget: (v, meta) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) return const SizedBox.shrink();
|
||||
final d = _filtered[idx]['date'] as DateTime;
|
||||
return Text('${d.month}/${d.day}', style: const TextStyle(fontSize: 9, color: Color(0xFFBBBBBB)));
|
||||
},
|
||||
)),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
color: _color,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 60,
|
||||
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
|
||||
radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white,
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(show: true,
|
||||
color: _color.withAlpha(20)),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots.map((s) {
|
||||
final idx = s.x.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) return null;
|
||||
final r = _filtered[idx];
|
||||
final d = r['date'] as DateTime;
|
||||
final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}';
|
||||
return LineTooltipItem(
|
||||
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
||||
TextStyle(color: _color, fontSize: 12, fontWeight: FontWeight.w600),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordList() {
|
||||
if (_records.isEmpty) return const SizedBox.shrink();
|
||||
// ---- 历史记录列表 ----
|
||||
Widget _buildHistory() {
|
||||
if (_filtered.isEmpty) return const SizedBox.shrink();
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
..._records.where((r) => r['value'] != null).toList().reversed.take(20).map((r) {
|
||||
Row(children: [
|
||||
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
Text('${_filtered.length}条', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
..._filtered.reversed.take(30).map((r) {
|
||||
final date = r['date'] as DateTime;
|
||||
final v1 = r['value'];
|
||||
final st = _statusText(r);
|
||||
final sc = _statusColor(st);
|
||||
final abnormal = r['isAbnormal'] == true;
|
||||
String display;
|
||||
if (_isBP) {
|
||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
} else {
|
||||
display = '${r['value'] ?? '--'}';
|
||||
}
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(children: [
|
||||
Container(width: 40, height: 40, decoration: BoxDecoration(
|
||||
color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)),
|
||||
child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 18))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${date.month}/${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||||
const SizedBox(height: 4),
|
||||
Text(_isBP ? '${v1 ?? '--'}/${r['value2'] ?? '--'} $_unit' : '${v1 ?? '--'} $_unit',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
Text('${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
const SizedBox(height: 2),
|
||||
Text('$display $_unit', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700,
|
||||
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))),
|
||||
])),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: sc.withAlpha(20), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(st, style: TextStyle(fontSize: 12, color: sc, fontWeight: FontWeight.w500))),
|
||||
if (abnormal)
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('异常', style: TextStyle(fontSize: 11, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
String _getMetricIcon(String key) {
|
||||
switch (key) {
|
||||
case 'blood_pressure': return '🫀';
|
||||
case 'heart_rate': return '💓';
|
||||
case 'glucose': return '🩸';
|
||||
case 'spo2': return '🫁';
|
||||
case 'weight': return '⚖️';
|
||||
default: return '📊';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user