Files
AI-Health/health_app/lib/pages/chart/trend_page.dart
MingNian d82e006cf4 refactor: 死代码清理 + 趋势图重构 + 侧边栏交互优化 + 智能体卡片去延迟
- 死代码清理: 删除 AppCard/AppMenuItem/AppTabChip/AppButtons 等 4 个未使用 Widget 文件; 清理 common_widgets/enterprise_widgets/app_status_badge 中未使用 class; 移除 HealthService 等未使用方法; 后端移除 ExerciseService.CreateFromItemsAsync/HealthArchiveService.GetOrCreateAsync/MedicationAgentHandler 死分支/ChatRequest DTO
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格; X 轴日+月份; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层; 选中点变实心
- 侧边栏: 对话记录改单选删除(灰底高亮); 操作栏浮层修复触摸问题; 常用功能/健康仪表盘间距收紧; _Panel 去外框
- 智能体: 欢迎卡片去掉 400ms 延迟; 胶囊去阴影
- 其他: api_client IP 适配; 多页面 UI 微调; 新增 chat_provider/prelaunch_guardrails 测试
2026-07-09 15:04:02 +08:00

944 lines
35 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math';
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_design_tokens.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
import '../../providers/data_providers.dart';
const String defaultTrendMetricType = 'blood_pressure';
const Set<String> supportedTrendMetricTypes = {
'blood_pressure',
'heart_rate',
'glucose',
'spo2',
'weight',
};
String normalizeTrendMetricType(String? metricType) {
if (metricType == null) return defaultTrendMetricType;
return supportedTrendMetricTypes.contains(metricType)
? metricType
: defaultTrendMetricType;
}
/// 健康概览趋势页 — 五大指标合到一个页面
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 = defaultTrendMetricType;
List<Map<String, dynamic>> _allRecords = [];
List<Map<String, dynamic>> _filtered = [];
bool _loading = true;
int? _selectedIdx; // 当前选中的数据点_filtered 索引null = 未选中
/// 计算"好看"的坐标轴步长,最小为 1保证标签都是整数
/// 目标 3 段 = 4 个标签;若吸附后超过 3 段,自动放大步长。
double _niceStep(double range) {
if (range <= 0) return 1;
final target = range / 3;
if (target < 1) return 1;
final magnitude = pow(10, (log(target) / log(10)).floor()).toDouble();
final normalized = target / magnitude;
double nice;
if (normalized <= 1) {
nice = 1;
} else if (normalized <= 2) {
nice = 2;
} else if (normalized <= 5) {
nice = 5;
} else {
nice = 10;
}
return nice * magnitude;
}
/// 步长过大时,跳到下一个"好看"的步长1→2→5→10→20→50…
double _bumpStep(double step) {
final magnitude = pow(10, (log(step) / log(10)).floor()).toDouble();
final normalized = step / magnitude;
if (normalized < 1.5) return 2 * magnitude;
if (normalized < 4) return 5 * magnitude;
return 10 * magnitude;
}
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'] == normalizeTrendMetricType(_selected),
)['color']
as Color;
@override
void initState() {
super.initState();
_selected = normalizeTrendMetricType(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;
_selectedIdx = null;
});
_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;
}
AppToast.show(context, '录入失败', type: AppToastType.error);
}
},
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;
}
// Y 轴:吸附到"好看"的整数刻度,标签全部为整数,最多 4 个3 段)
var yStep = _niceStep(maxV - minV);
minV = (minV / yStep).floor() * yStep;
maxV = (maxV / yStep).ceil() * yStep;
while ((maxV - minV) / yStep > 3) {
yStep = _bumpStep(yStep);
minV = (minV / yStep).floor() * yStep;
maxV = (maxV / yStep).ceil() * yStep;
}
// X 轴刻度间隔:日标签短,可以排密一点
final xInterval = spots.length > 60
? 5.0
: spots.length > 30
? 2.0
: 1.0;
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: LayoutBuilder(
builder: (context, constraints) {
final chartWidth = constraints.maxWidth;
final paintWidth = chartWidth - 40.0; // 减左侧轴
final paintHeight = 200.0 - 36.0; // 减底部轴
// 计算选中点的像素位置
Offset? tooltipPos;
String? tooltipText1;
String? tooltipText2;
if (_selectedIdx != null &&
_selectedIdx! >= 0 &&
_selectedIdx! < _filtered.length) {
final r = _filtered[_selectedIdx!];
final v = _isBP
? (r['systolic'] as num?)?.toDouble()
: (r['value'] as num?)?.toDouble();
if (v != null && spots.length > 1) {
final px =
40.0 +
(_selectedIdx! / (spots.length - 1)) * paintWidth;
final py = paintHeight * (1 - (v - minV) / (maxV - minV));
final d = r['date'] as DateTime;
tooltipPos = Offset(px, py);
tooltipText1 =
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
tooltipText2 = _isBP
? '${r['systolic']}/${r['diastolic']} $_unit'
: '$v $_unit';
}
}
return Stack(
clipBehavior: Clip.none,
children: [
LineChart(
LineChartData(
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: minV,
maxY: maxV,
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: yStep,
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(right: 6),
child: Text(
v.toStringAsFixed(0),
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
interval: xInterval,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) {
return const SizedBox.shrink();
}
final d = _filtered[idx]['date'] as DateTime;
// 当前可见刻度与上一个可见刻度相比,月份是否变化
final prevIdx = idx - xInterval.toInt();
final showMonth =
idx == 0 ||
prevIdx < 0 ||
(_filtered[prevIdx]['date'] as DateTime)
.month !=
d.month;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${d.day}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
if (showMonth) ...[
const SizedBox(height: 2),
Text(
'${d.month}',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
],
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
color: _color,
barWidth: 2.5,
isStrokeCapRound: true,
dotData: FlDotData(
show: spots.length <= 30,
getDotPainter: (spot, _, _, _) {
final isSelected =
spot.x.toInt() == _selectedIdx;
if (isSelected) {
return FlDotCirclePainter(
radius: 5.5,
color: _color,
strokeWidth: 2.5,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(
radius: 3.5,
color: Colors.white,
strokeWidth: 2,
strokeColor: _color,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_color.withValues(alpha: 0.22),
_color.withValues(alpha: 0.02),
],
),
),
shadow: Shadow(
color: _color.withValues(alpha: 0.18),
blurRadius: 6,
offset: const Offset(0, 3),
),
),
],
lineTouchData: LineTouchData(
handleBuiltInTouches: false,
touchCallback: (event, response) {
if (event is FlTapUpEvent) {
final spots = response?.lineBarSpots;
if (spots != null && spots.isNotEmpty) {
final idx = spots.first.x.toInt();
setState(
() => _selectedIdx = (_selectedIdx == idx)
? null
: idx,
);
} else {
setState(() => _selectedIdx = null);
}
}
},
),
),
duration: Duration.zero,
),
if (tooltipPos != null)
Positioned(
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
top: tooltipPos.dy - 56,
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _color.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: const Color(
0xFF101828,
).withValues(alpha: 0.14),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tooltipText1!,
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
tooltipText2!,
style: TextStyle(
fontSize: 15,
color: _color,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
],
);
},
),
),
],
),
);
}
// ---- 历史记录列表 ----
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),
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
),
child: Column(
children: [
..._filtered.reversed.take(30).toList().asMap().entries.map((
entry,
) {
final r = entry.value;
final isLast =
entry.key == _filtered.reversed.take(30).length - 1;
final date = r['date'] as DateTime;
final abnormal = r['isAbnormal'] == true;
final id = r['id']?.toString() ?? '';
final idx = _filtered.indexOf(r);
final isSelected = idx == _selectedIdx;
String display;
if (_isBP) {
display =
'${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
} else {
display = '${r['value'] ?? '--'}';
}
return Dismissible(
key: Key(id),
direction: DismissDirection.endToStart,
background: Container(
color: AppColors.error,
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);
_selectedIdx = null;
});
return true;
} catch (_) {
return false;
}
},
child: GestureDetector(
onTap: () {
if (idx < 0) return;
setState(() => _selectedIdx = isSelected ? null : idx);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
color: isSelected
? _color.withValues(alpha: 0.10)
: Colors.white,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _color.withAlpha(20),
borderRadius: AppRadius.smBorder,
),
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: AppRadius.xsBorder,
),
child: const Text(
'异常',
style: TextStyle(
fontSize: 14,
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
if (!isLast)
const Padding(
padding: EdgeInsets.only(left: 66),
child: Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
),
),
),
);
}),
],
),
),
],
);
}
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;
}
}
}