Files
AI-Health/health_app/lib/pages/chart/trend_page.dart
MingNian 3b5cec10a6 feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router)
- 新增 AI 同意门控(用户需同意后才能使用 AI 功能)
- 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord)
- 新增 AI 意图路由(ai_intent_router)
- 新增医疗引用知识库(MedicalCitationKnowledge)
- 重构 prompt_manager 和 ai_chat_endpoints
- 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI
- 新增 agent 插画和趋势指标图标资源
- 删除 HANDOFF-2026-07-17.md
2026-07-27 16:53:20 +08:00

1560 lines
55 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_module_visuals.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';
import '../../widgets/common_widgets.dart';
import '../../widgets/app_empty_state.dart';
const String defaultTrendMetricType = 'blood_pressure';
const Set<String> supportedTrendMetricTypes = {
'blood_pressure',
'heart_rate',
'glucose',
'spo2',
'weight',
};
class _TrendColors {
_TrendColors._();
static const bloodPressure = HealthMetricVisuals.bloodPressureColor;
static const heartRate = HealthMetricVisuals.heartRateColor;
static const glucose = HealthMetricVisuals.glucoseColor;
static const spo2 = HealthMetricVisuals.spo2Color;
static const weight = HealthMetricVisuals.weightColor;
static const systolic = bloodPressure;
static const diastolic = Color(0xFF3B82F6);
}
String normalizeTrendMetricType(String? metricType) {
if (metricType == null) return defaultTrendMetricType;
return supportedTrendMetricTypes.contains(metricType)
? metricType
: defaultTrendMetricType;
}
List<Map<String, dynamic>> filterTrendRecordsForPeriod(
List<Map<String, dynamic>> records,
int days,
DateTime now,
) {
final today = DateTime(now.year, now.month, now.day);
final start = today.subtract(Duration(days: days - 1));
return records.where((record) {
final date = record['date'];
return date is DateTime && !date.isBefore(start);
}).toList();
}
String formatTrendNumber(num? value) {
if (value == null) return '--';
final number = value.toDouble();
return number == number.roundToDouble()
? number.toInt().toString()
: number.toStringAsFixed(1);
}
String trendStatusLabel(Map<String, dynamic> record, String metricType) {
if (record['isAbnormal'] != true) return '';
if (metricType != 'blood_pressure') return '异常';
final systolic = (record['systolic'] as num?)?.toDouble();
final diastolic = (record['diastolic'] as num?)?.toDouble();
if (systolic != null && systolic >= 140) return '收缩压偏高';
if (diastolic != null && diastolic >= 90) return '舒张压偏高';
if ((systolic != null && systolic < 90) ||
(diastolic != null && diastolic < 60)) {
return '血压偏低';
}
return '血压异常';
}
/// 健康概览趋势页 — 五大指标合到一个页面
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 = [];
Set<String> _failedTypes = {};
bool _loading = true;
int _periodDays = 7;
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': _TrendColors.bloodPressure,
'icon': HealthMetricVisuals.bloodPressureIcon,
},
{
'key': 'heart_rate',
'label': '心率',
'color': _TrendColors.heartRate,
'icon': HealthMetricVisuals.heartRateIcon,
},
{
'key': 'glucose',
'label': '血糖',
'color': _TrendColors.glucose,
'icon': HealthMetricVisuals.glucoseIcon,
},
{
'key': 'spo2',
'label': '血氧',
'color': _TrendColors.spo2,
'icon': HealthMetricVisuals.spo2Icon,
},
{
'key': 'weight',
'label': '体重',
'color': _TrendColors.weight,
'icon': HealthMetricVisuals.weightIcon,
},
];
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': '体重',
};
static const _latestCardArt = {
'blood_pressure': 'assets/branding/trend_metric_blood_pressure.png',
'heart_rate': 'assets/branding/trend_metric_heart_rate.png',
'glucose': 'assets/branding/trend_metric_glucose.png',
'spo2': 'assets/branding/trend_metric_spo2.png',
'weight': 'assets/branding/trend_metric_weight.png',
};
String get _unit => _units[_selected] ?? '';
String get _name => _names[_selected] ?? '';
bool get _isBP => _selected == 'blood_pressure';
String get _selectedApiType => _apiTypeForMetric(_selected);
List<Map<String, dynamic>> get _chartRecords =>
filterTrendRecordsForPeriod(_filtered, _periodDays, DateTime.now());
Map<String, dynamic>? get _latestRecord =>
_filtered.isEmpty ? null : _filtered.last;
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 failedTypes = <String>{};
final groups = await Future.wait(
types.map((type) async {
try {
final res = await api.get(
'/api/health-records/trend',
queryParameters: {'type': type, 'period': 365},
);
final list =
(res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
return list.map((record) {
return <String, dynamic>{
'id': record['id'],
'type': type,
'date':
DateTime.tryParse(
record['recordedAt']?.toString() ?? '',
)?.toLocal() ??
DateTime.now(),
'systolic': record['systolic'],
'diastolic': record['diastolic'],
'value': record['value'],
'unit': record['unit'] ?? '',
'isAbnormal': record['isAbnormal'] == true,
};
}).toList();
} catch (e) {
failedTypes.add(type);
debugPrint('[Trend] 加载 $type 趋势失败: $e');
return <Map<String, dynamic>>[];
}
}),
);
final all = groups.expand((group) => group).toList();
all.sort(
(a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime),
);
if (mounted) {
setState(() {
_allRecords = all;
_failedTypes = failedTypes;
_loading = false;
});
_filter();
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
void _filter() {
setState(() {
_filtered = _allRecords
.where((record) => record['type'] == _selectedApiType)
.toList();
_filtered.sort(
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
);
});
}
void _switchMetric(String key) {
setState(() {
_selected = key;
_periodDays = key == 'weight' ? 30 : 7;
_selectedIdx = null;
});
_filter();
}
String _apiTypeForMetric(String key) {
return switch (key) {
'blood_pressure' => 'BloodPressure',
'heart_rate' => 'HeartRate',
'glucose' => 'Glucose',
'spo2' => 'SpO2',
_ => 'Weight',
};
}
// ---- 手动录入 ----
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) => _KeyboardInsetPadding(
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: AppGradientOutlineButton(
label: '确认录入',
onPressed: () async {
final v1 = double.tryParse(ctrl1.text.trim());
final v2 = _isBP ? double.tryParse(ctrl2.text.trim()) : null;
if (v1 == null || (_isBP && v2 == null)) {
AppToast.show(
ctx,
_isBP ? '请完整填写收缩压和舒张压' : '请输入有效的$_name数值',
type: AppToastType.error,
);
return;
}
if (_isBP &&
(v1 < 50 ||
v1 > 250 ||
v2! < 30 ||
v2 > 150 ||
v1 <= v2)) {
AppToast.show(
ctx,
'请检查血压数值,收缩压应高于舒张压',
type: AppToastType.error,
);
return;
}
try {
final api = ref.read(apiClientProvider);
final body = <String, dynamic>{
'type': _selectedApiType,
'source': 'Manual',
'recordedAt': DateTime.now().toUtc().toIso8601String(),
'unit': _unit,
};
if (_isBP) {
body['systolic'] = v1.toInt();
body['diastolic'] = v2!.toInt();
} 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,
);
}
},
),
),
const SizedBox(height: 20),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return GradientScaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
title: const Text('健康概览'),
centerTitle: true,
),
floatingActionButton: AppCreateFab(
tooltip: '添加健康记录',
onPressed: _showAddDialog,
),
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: [
_buildMetricOverview(),
const SizedBox(height: 16),
_buildTrendInsightPanel(),
const SizedBox(height: 24),
_buildHistory(),
const SizedBox(height: 80),
],
),
),
),
);
}
// ---- 顶部指标概览,同时作为切换入口 ----
Widget _buildMetricOverview() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.pillBorder,
border: Border.all(color: AppColors.divider.withValues(alpha: 0.7)),
),
child: Row(
children: _metrics.map((m) {
final key = m['key'] as String;
final color = m['color'] as Color;
final sel = _selected == key;
return Expanded(
child: InkWell(
onTap: () => _switchMetric(key),
borderRadius: AppRadius.pillBorder,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: sel ? color : Colors.transparent,
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
m['icon'] as IconData,
size: 16,
color: sel ? Colors.white : color,
),
const SizedBox(width: 4),
Flexible(
child: Text(
m['label'] as String,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: sel ? Colors.white : AppColors.textSecondary,
),
),
),
],
),
),
),
);
}).toList(),
),
);
}
Widget _buildLatestSummary() {
final record = _latestRecord!;
final date = record['date'] as DateTime;
final status = trendStatusLabel(record, _selected);
final abnormal = status.isNotEmpty;
final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!;
return SizedBox(
height: 214,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: 0,
right: -48,
top: -12,
bottom: -12,
child: Image.asset(
art,
fit: BoxFit.cover,
alignment: Alignment.centerRight,
),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.white.withValues(alpha: 0.96),
Colors.white.withValues(alpha: 0.72),
Colors.white.withValues(alpha: 0.04),
],
stops: const [0, 0.42, 0.78],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(6, 8, 178, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Icon(
_getMetricIcon(_selected),
color: _color,
size: 23,
),
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'最新$_name',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
Text(
_formatRecordTime(date),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
const SizedBox(height: 18),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.end,
spacing: 7,
runSpacing: 4,
children: [
Text(
_displayValue(record),
style: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 0.98,
),
),
],
),
),
const SizedBox(height: 8),
_StatusBadge(
label: abnormal ? status : '正常',
abnormal: abnormal,
),
],
),
),
Positioned(
left: 6,
right: 178,
bottom: 8,
child: _buildPeriodSelector(),
),
],
),
);
}
Widget _buildTrendInsightPanel() {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.cardBorder,
boxShadow: AppShadows.panel,
),
child: Column(
children: [
if (_latestRecord != null) ...[
_buildLatestSummary(),
const SizedBox(height: 4),
],
if (_latestRecord == null) ...[
_buildPeriodSelector(),
const SizedBox(height: 14),
],
_buildChart(),
if (_chartRecords.isNotEmpty) ...[
const SizedBox(height: 4),
_buildStatistics(),
],
],
),
);
}
Widget _buildPeriodSelector() {
return Container(
constraints: const BoxConstraints(maxWidth: 310),
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.06),
borderRadius: AppRadius.lgBorder,
border: Border.all(color: _color.withValues(alpha: 0.14)),
),
child: Row(
children: [
for (final days in const [7, 30, 90])
Expanded(
child: InkWell(
onTap: () => setState(() {
_periodDays = days;
_selectedIdx = null;
}),
borderRadius: AppRadius.smBorder,
child: Container(
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _periodDays == days
? _color.withValues(alpha: 0.92)
: Colors.transparent,
borderRadius: AppRadius.smBorder,
),
child: Text(
'$days天',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: _periodDays == days
? Colors.white
: AppColors.textSecondary,
),
),
),
),
),
],
),
);
}
Widget _buildStatistics() {
final records = _chartRecords;
final values = records
.map((record) => (record['value'] as num?)?.toDouble())
.whereType<double>()
.toList();
String average;
String highest;
String lowest;
if (_isBP) {
final systolic = records
.map((record) => (record['systolic'] as num?)?.toDouble())
.whereType<double>()
.toList();
final diastolic = records
.map((record) => (record['diastolic'] as num?)?.toDouble())
.whereType<double>()
.toList();
average = systolic.isEmpty || diastolic.isEmpty
? '--'
: '${(systolic.reduce((a, b) => a + b) / systolic.length).round()}/${(diastolic.reduce((a, b) => a + b) / diastolic.length).round()}';
final bpRecords = records.where((record) {
return record['systolic'] is num && record['diastolic'] is num;
});
final highestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
current,
record,
) {
if (current == null) return record;
final currentValue = (current['systolic'] as num).toDouble();
final recordValue = (record['systolic'] as num).toDouble();
return recordValue > currentValue ? record : current;
});
final lowestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
current,
record,
) {
if (current == null) return record;
final currentValue = (current['systolic'] as num).toDouble();
final recordValue = (record['systolic'] as num).toDouble();
return recordValue < currentValue ? record : current;
});
highest = highestRecord == null ? '--' : _displayValue(highestRecord);
lowest = lowestRecord == null ? '--' : _displayValue(lowestRecord);
} else {
average = values.isEmpty
? '--'
: formatTrendNumber(values.reduce((a, b) => a + b) / values.length);
highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max));
lowest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(min));
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.045),
borderRadius: AppRadius.xlBorder,
),
child: Row(
children: [
_StatisticItem(label: '平均', value: average, unit: _unit),
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
_StatisticItem(label: '最高', value: highest, unit: _unit),
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
_StatisticItem(label: '最低', value: lowest, unit: _unit),
],
),
);
}
String _displayValue(Map<String, dynamic> record) {
if (_isBP) {
return '${formatTrendNumber(record['systolic'] as num?)}/${formatTrendNumber(record['diastolic'] as num?)}';
}
return formatTrendNumber(record['value'] as num?);
}
String _formatRecordTime(DateTime date) {
final now = DateTime.now();
final sameDay =
now.year == date.year && now.month == date.month && now.day == date.day;
final time =
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return sameDay ? '今天 $time' : '${date.month}${date.day}$time';
}
// ---- 趋势图 ----
Widget _buildChart() {
if (_failedTypes.contains(_selectedApiType)) {
return Container(
height: 220,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_off_outlined, color: AppColors.textHint),
const SizedBox(height: 8),
Text('$_name数据加载失败'),
TextButton.icon(
onPressed: _loadAll,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新加载'),
),
],
),
);
}
final records = _chartRecords;
if (records.isEmpty) {
return Container(
height: 220,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
child: AppEmptyState(
icon: _getMetricIcon(_selected),
iconColor: _color,
title: '$_periodDays天没有$_name数据',
subtitle: '点击右下角 + 手动录入',
padding: const EdgeInsets.all(20),
),
);
}
final spots = <FlSpot>[];
final diastolicSpots = <FlSpot>[];
double minV = double.infinity, maxV = double.negativeInfinity;
for (int i = 0; i < records.length; i++) {
final r = records[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 (_isBP) {
final diastolic = (r['diastolic'] as num?)?.toDouble();
if (diastolic != null) {
minV = min(minV, diastolic);
maxV = max(maxV, diastolic);
diastolicSpots.add(FlSpot(i.toDouble(), diastolic));
}
}
}
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 轴刻度间隔:日标签短,可以排密一点
const xInterval = 1.0;
final dayStartIndices = <int>[];
for (var i = 0; i < records.length; i++) {
if (i == 0 ||
!_isSameChartDay(
records[i - 1]['date'] as DateTime,
records[i]['date'] as DateTime,
)) {
dayStartIndices.add(i);
}
}
final dayLabelStep = dayStartIndices.length > 7
? (dayStartIndices.length / 7).ceil()
: 1;
final visibleDayLabels = <int>{
for (var i = 0; i < dayStartIndices.length; i += dayLabelStep)
dayStartIndices[i],
if (dayStartIndices.isNotEmpty) dayStartIndices.last,
};
return Container(
padding: const EdgeInsets.fromLTRB(4, 10, 12, 6),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: AppRadius.lgBorder,
),
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(
'趋势 (${records.length}条)',
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
),
const Spacer(),
Text(
'单位: $_unit',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
if (_isBP) ...[
const SizedBox(height: 8),
const Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_ChartLegend(color: _TrendColors.systolic, label: '收缩压'),
SizedBox(width: 16),
_ChartLegend(color: _TrendColors.diastolic, label: '舒张压'),
],
),
],
const SizedBox(height: 10),
SizedBox(
height: 200,
child: LayoutBuilder(
builder: (context, constraints) {
final chartWidth = constraints.maxWidth;
final paintWidth = chartWidth - 32.0; // 减左侧轴
final paintHeight = 200.0 - 36.0; // 减底部轴
// 计算选中点的像素位置
Offset? tooltipPos;
String? tooltipText1;
String? tooltipText2;
if (_selectedIdx != null &&
_selectedIdx! >= 0 &&
_selectedIdx! < records.length) {
final r = records[_selectedIdx!];
final v = _isBP
? (r['systolic'] as num?)?.toDouble()
: (r['value'] as num?)?.toDouble();
if (v != null && spots.length > 1) {
final px =
32.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: max(1, spots.length - 1).toDouble(),
minY: minV,
maxY: maxV,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yStep,
getDrawingHorizontalLine: (_) => const FlLine(
color: AppColors.divider,
strokeWidth: 0.8,
dashArray: [4, 4],
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
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 >= records.length) {
return const SizedBox.shrink();
}
if (!visibleDayLabels.contains(idx)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_chartAxisLabel(records, idx),
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.28,
color: _color,
barWidth: 3,
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.18),
_color.withValues(alpha: 0.02),
],
),
),
),
if (_isBP && diastolicSpots.isNotEmpty)
LineChartBarData(
spots: diastolicSpots,
isCurved: true,
curveSmoothness: 0.28,
color: _TrendColors.diastolic,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: diastolicSpots.length <= 30,
getDotPainter: (spot, _, _, _) =>
FlDotCirclePainter(
radius: spot.x.toInt() == _selectedIdx
? 5.5
: 3.5,
color: Colors.white,
strokeWidth: 2,
strokeColor: _TrendColors.diastolic,
),
),
belowBarData: BarAreaData(
show: true,
color: _TrendColors.diastolic.withValues(
alpha: 0.05,
),
),
),
],
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: AppRadius.xsBorder,
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,
),
),
],
),
),
),
),
],
);
},
),
),
],
),
);
}
String _chartAxisLabel(List<Map<String, dynamic>> records, int index) {
final date = records[index]['date'] as DateTime;
return '${date.month}-${date.day}';
}
bool _isSameChartDay(DateTime first, DateTime second) {
return first.year == second.year &&
first.month == second.month &&
first.day == second.day;
}
// ---- 历史记录列表 ----
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,
boxShadow: AppShadows.soft,
),
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 chartRecords = _chartRecords;
final selectedId =
_selectedIdx != null &&
_selectedIdx! >= 0 &&
_selectedIdx! < chartRecords.length
? chartRecords[_selectedIdx!]['id']?.toString()
: null;
final isSelected = selectedId == id;
final display = _displayValue(r);
final status = trendStatusLabel(r, _selected);
return Dismissible(
key: ValueKey('$id-${date.microsecondsSinceEpoch}'),
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');
return true;
} catch (_) {
if (mounted) {
AppToast.show(
context,
'删除失败,请检查网络后重试',
type: AppToastType.error,
);
}
return false;
}
},
onDismissed: (_) {
setState(() {
_allRecords.removeWhere(
(record) => record['id']?.toString() == id,
);
_filtered.removeWhere(
(record) => record['id']?.toString() == id,
);
_selectedIdx = null;
});
ref.invalidate(latestHealthProvider);
},
child: GestureDetector(
onTap: () {
final chartIndex = chartRecords.indexWhere(
(record) => record['id']?.toString() == id,
);
if (chartIndex < 0) return;
setState(
() => _selectedIdx = isSelected ? null : chartIndex,
);
},
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: 10,
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _color.withAlpha(20),
borderRadius: AppRadius.smBorder,
),
child: Center(
child: Icon(
_getMetricIcon(_selected),
size: 19,
color: _color,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_formatRecordTime(date),
maxLines: 1,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 6),
_StatusBadge(
label: abnormal ? status : '正常',
abnormal: abnormal,
),
],
),
),
const SizedBox(width: 8),
SizedBox(
width: 116,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
'$display $_unit',
maxLines: 1,
style: const TextStyle(
fontSize: 21,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
),
],
),
),
if (!isLast)
const Padding(
padding: EdgeInsets.only(left: 62),
child: Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
),
),
),
);
}),
],
),
),
],
);
}
IconData _getMetricIcon(String key) {
return _metrics.firstWhere(
(metric) => metric['key'] == key,
orElse: () => _metrics.first,
)['icon']
as IconData;
}
}
class _KeyboardInsetPadding extends StatelessWidget {
final Widget child;
const _KeyboardInsetPadding({required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
left: 20,
right: 20,
top: 20,
),
child: RepaintBoundary(child: child),
);
}
}
class _StatisticItem extends StatelessWidget {
final String label;
final String value;
final String unit;
const _StatisticItem({
required this.label,
required this.value,
required this.unit,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
unit,
style: const TextStyle(fontSize: 11, color: AppColors.textHint),
),
],
),
);
}
}
class _StatusBadge extends StatelessWidget {
final String label;
final bool abnormal;
const _StatusBadge({required this.label, required this.abnormal});
@override
Widget build(BuildContext context) {
final color = abnormal ? AppColors.warningText : AppColors.successText;
final background = abnormal
? AppColors.warningLight
: AppColors.successLight;
return Container(
constraints: const BoxConstraints(maxWidth: 78),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: AppRadius.pillBorder,
border: Border.all(color: color.withValues(alpha: 0.18)),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
height: 1,
),
),
),
);
}
}
class _ChartLegend extends StatelessWidget {
final Color color;
final String label;
const _ChartLegend({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 14,
height: 3,
decoration: BoxDecoration(
color: color,
borderRadius: AppRadius.pillBorder,
),
),
const SizedBox(width: 5),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
],
);
}
}