- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI - 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片 - 报告模块: POST端点 + report_agent_handler接VLM - 健康日历: GET /api/calendar端点 + 前端接API - 趋势页重写: 删除Mock数据 + 真实API + 手动录入 - 饮食保存: submit按钮接后端API - 侧边栏: 健康概览横排 + 统一紫色配色 - 运动计划: 修复JSON反序列化(record→手动解析) - VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩 - UI颜色: 加深主色 8B9CF7→6C5CE7 - 个人信息页精简 + 健康档案可编辑重设计 - 保洁: 删除无用agent_bar + 删除意见反馈/关于我们 - 新增AI提示词文档
255 lines
11 KiB
Dart
255 lines
11 KiB
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});
|
|
|
|
@override ConsumerState<TrendPage> createState() => _TrendPageState();
|
|
}
|
|
|
|
class _TrendPageState extends ConsumerState<TrendPage> {
|
|
int _period = 7;
|
|
List<Map<String, dynamic>> _records = [];
|
|
bool _loading = true;
|
|
|
|
final _value1Ctrl = TextEditingController();
|
|
final _value2Ctrl = TextEditingController();
|
|
|
|
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';
|
|
|
|
@override void initState() { super.initState(); _load(); }
|
|
@override void dispose() { _value1Ctrl.dispose(); _value2Ctrl.dispose(); super.dispose(); }
|
|
|
|
Future<void> _load() 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; });
|
|
} 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)));
|
|
}
|
|
}
|
|
|
|
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 '异常';
|
|
}
|
|
|
|
Color _statusColor(String s) {
|
|
switch (s) {
|
|
case '偏高': return const Color(0xFFE53935);
|
|
case '偏低': return const Color(0xFFFF9800);
|
|
default: return const Color(0xFF43A047);
|
|
}
|
|
}
|
|
|
|
@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),
|
|
])),
|
|
);
|
|
}
|
|
|
|
Widget _buildChartSection() {
|
|
final valid = _records.where((r) => r['value'] != null).toList();
|
|
if (valid.isEmpty) {
|
|
return Container(
|
|
height: 200, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
|
child: const Center(child: Text('暂无数据,请在下方手动录入', style: TextStyle(color: Color(0xFFBBBBBB)))),
|
|
);
|
|
}
|
|
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
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
|
child: Column(children: [
|
|
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
_chip('7天', 7), _chip('30天', 30), _chip('90天', 90),
|
|
]),
|
|
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: 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('记录')),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
Widget _buildRecordList() {
|
|
if (_records.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) {
|
|
final date = r['date'] as DateTime;
|
|
final v1 = r['value'];
|
|
final st = _statusText(r);
|
|
final sc = _statusColor(st);
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
|
child: Row(children: [
|
|
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)),
|
|
])),
|
|
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))),
|
|
]),
|
|
);
|
|
}),
|
|
]);
|
|
}
|
|
}
|