- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID) - 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿 - 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据 - 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送) - 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect) - 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认 - 顺手更新本机开发 IP 与清理过时设计文档
697 lines
23 KiB
Dart
697 lines
23 KiB
Dart
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/app_colors.dart';
|
|
import '../../core/app_theme.dart';
|
|
import '../../core/navigation_provider.dart';
|
|
import '../../providers/auth_provider.dart';
|
|
import '../../providers/data_providers.dart';
|
|
|
|
/// 健康概览趋势页 — 五大指标合到一个页面
|
|
class TrendPage extends ConsumerStatefulWidget {
|
|
final String? metricType; // 可选初始选中指标
|
|
const TrendPage({super.key, this.metricType});
|
|
|
|
@override
|
|
ConsumerState<TrendPage> createState() => _TrendPageState();
|
|
}
|
|
|
|
class _TrendPageState extends ConsumerState<TrendPage> {
|
|
String _selected = 'blood_pressure';
|
|
List<Map<String, dynamic>> _allRecords = [];
|
|
List<Map<String, dynamic>> _filtered = [];
|
|
bool _loading = true;
|
|
|
|
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 _units = {
|
|
'blood_pressure': 'mmHg',
|
|
'heart_rate': 'bpm',
|
|
'glucose': 'mmol/L',
|
|
'spo2': '%',
|
|
'weight': 'kg',
|
|
};
|
|
|
|
static const _names = {
|
|
'blood_pressure': '血压',
|
|
'heart_rate': '心率',
|
|
'glucose': '血糖',
|
|
'spo2': '血氧',
|
|
'weight': '体重',
|
|
};
|
|
|
|
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;
|
|
|
|
@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 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({
|
|
'id': r['id'],
|
|
'type': t,
|
|
'date':
|
|
DateTime.tryParse(
|
|
r['recordedAt']?.toString() ?? '',
|
|
)?.toLocal() ??
|
|
DateTime.now(),
|
|
'systolic': r['systolic'],
|
|
'diastolic': r['diastolic'],
|
|
'value': r['value'],
|
|
'unit': r['unit'] ?? '',
|
|
'isAbnormal': r['isAbnormal'] == true,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[Trend] 加载趋势失败: $e');
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
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),
|
|
);
|
|
});
|
|
}
|
|
|
|
void _switchMetric(String key) {
|
|
setState(() => _selected = key);
|
|
_filter();
|
|
}
|
|
|
|
// ---- 手动录入 ----
|
|
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: 21, 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().toUtc().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);
|
|
if (!ctx.mounted || !mounted) {
|
|
return;
|
|
}
|
|
Navigator.pop(ctx);
|
|
ref.invalidate(latestHealthProvider);
|
|
_loadAll();
|
|
} catch (_) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
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: 19)),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GradientScaffold(
|
|
appBar: AppBar(
|
|
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
|
title: const Text('健康概览'),
|
|
centerTitle: true,
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: _showAddDialog,
|
|
backgroundColor: _color,
|
|
child: const Icon(Icons.add),
|
|
),
|
|
body: _loading
|
|
? const Center(
|
|
child: CircularProgressIndicator(color: AppTheme.primary),
|
|
)
|
|
: 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 _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 : AppColors.border),
|
|
),
|
|
child: Text(
|
|
m['label'] as String,
|
|
style: TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w600,
|
|
color: sel ? Colors.white : AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---- 趋势图 ----
|
|
Widget _buildChart() {
|
|
if (_filtered.isEmpty) {
|
|
return Container(
|
|
height: 220,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppColors.border),
|
|
boxShadow: AppColors.cardShadowLight,
|
|
),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.show_chart,
|
|
size: 48,
|
|
color: AppColors.textHint.withValues(alpha: 0.45),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'暂无$_name数据',
|
|
style: const TextStyle(color: AppColors.textHint),
|
|
),
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
'点击右下角 + 手动录入',
|
|
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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.fromLTRB(8, 20, 16, 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppColors.border),
|
|
boxShadow: AppColors.cardShadowLight,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_name,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
color: _color,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'趋势 (${_filtered.length}条)',
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'单位: $_unit',
|
|
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
|
),
|
|
],
|
|
),
|
|
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: 13,
|
|
color: AppColors.textHint,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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: 12,
|
|
color: AppColors.textHint,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
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: 15,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---- 历史记录列表 ----
|
|
Widget _buildHistory() {
|
|
if (_filtered.isEmpty) return const SizedBox.shrink();
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Text(
|
|
'历史记录',
|
|
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'${_filtered.length}条',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
..._filtered.reversed.take(30).map((r) {
|
|
final date = r['date'] as DateTime;
|
|
final abnormal = r['isAbnormal'] == true;
|
|
final id = r['id']?.toString() ?? '';
|
|
String display;
|
|
if (_isBP) {
|
|
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
|
} else {
|
|
display = '${r['value'] ?? '--'}';
|
|
}
|
|
return Dismissible(
|
|
key: Key(id),
|
|
direction: DismissDirection.endToStart,
|
|
background: Container(
|
|
margin: const EdgeInsets.only(bottom: 6),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.error,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20),
|
|
child: const Icon(Icons.delete_outline, color: Colors.white),
|
|
),
|
|
confirmDismiss: (_) async {
|
|
try {
|
|
final api = ref.read(apiClientProvider);
|
|
await api.delete('/api/health-records/$id');
|
|
setState(() {
|
|
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
|
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
|
});
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
},
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 6),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: AppColors.border),
|
|
boxShadow: AppColors.cardShadowLight,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: _color.withAlpha(20),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Center(
|
|
child: Icon(
|
|
_getMetricIcon(_selected),
|
|
size: 21,
|
|
color: _color,
|
|
),
|
|
),
|
|
),
|
|
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: 15,
|
|
color: AppColors.textHint,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'$display $_unit',
|
|
style: TextStyle(
|
|
fontSize: 23,
|
|
fontWeight: FontWeight.w700,
|
|
color: abnormal
|
|
? AppColors.error
|
|
: AppColors.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (abnormal)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.errorLight,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: const Text(
|
|
'异常',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppColors.error,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
IconData _getMetricIcon(String key) {
|
|
switch (key) {
|
|
case 'blood_pressure':
|
|
return Icons.monitor_heart_outlined;
|
|
case 'heart_rate':
|
|
return Icons.favorite_border;
|
|
case 'glucose':
|
|
return Icons.water_drop_outlined;
|
|
case 'spo2':
|
|
return Icons.air_outlined;
|
|
case 'weight':
|
|
return Icons.monitor_weight_outlined;
|
|
default:
|
|
return Icons.show_chart;
|
|
}
|
|
}
|
|
}
|