Files
AI-Health/health_app/lib/pages/chart/trend_page.dart
MingNian fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00

1376 lines
48 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 'package:shadcn_ui/shadcn_ui.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';
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 = Color(0xFFD45B68);
static const heartRate = Color(0xFFD97757);
static const glucose = Color(0xFF4F6EF7);
static const spo2 = Color(0xFF168F7A);
static const weight = Color(0xFF7559C7);
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': LucideIcons.gauge,
},
{
'key': 'heart_rate',
'label': '心率',
'color': _TrendColors.heartRate,
'icon': LucideIcons.heartPulse,
},
{
'key': 'glucose',
'label': '血糖',
'color': _TrendColors.glucose,
'icon': LucideIcons.droplet,
},
{
'key': 'spo2',
'label': '血氧',
'color': _TrendColors.spo2,
'icon': LucideIcons.wind,
},
{
'key': 'weight',
'label': '体重',
'color': _TrendColors.weight,
'icon': LucideIcons.scale,
},
];
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';
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) => 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: 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(
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: [
_buildMetricTabs(),
const SizedBox(height: 16),
if (_latestRecord != null) ...[
_buildLatestSummary(),
const SizedBox(height: 16),
],
_buildPeriodSelector(),
const SizedBox(height: 12),
_buildChart(),
if (_chartRecords.isNotEmpty) ...[
const SizedBox(height: 12),
_buildStatistics(),
],
const SizedBox(height: 24),
_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 ? AppColors.primarySoft : Colors.white,
borderRadius: AppRadius.xlBorder,
border: Border.all(
color: sel ? AppColors.primary : AppColors.borderLight,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
m['icon'] as IconData,
size: 17,
color: sel ? AppColors.primaryDark : color,
),
const SizedBox(width: 6),
Text(
m['label'] as String,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: sel
? AppColors.primaryDark
: AppColors.textSecondary,
),
),
],
),
),
);
}).toList(),
),
);
}
Widget _buildLatestSummary() {
final record = _latestRecord!;
final date = record['date'] as DateTime;
final status = trendStatusLabel(record, _selected);
final abnormal = status.isNotEmpty;
return Container(
width: double.infinity,
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.10),
borderRadius: AppRadius.mdBorder,
),
child: Icon(_getMetricIcon(_selected), color: _color, size: 25),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'最新$_name',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 4),
Wrap(
crossAxisAlignment: WrapCrossAlignment.end,
spacing: 6,
children: [
Text(
_displayValue(record),
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.05,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
_unit,
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
),
),
),
],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
abnormal ? status : '正常',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: abnormal ? AppColors.errorText : AppColors.successText,
),
),
const SizedBox(height: 6),
Text(
_formatRecordTime(date),
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
),
],
),
],
),
);
}
Widget _buildPeriodSelector() {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.mdBorder,
),
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: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _periodDays == days
? AppColors.primaryLight
: Colors.transparent,
borderRadius: AppRadius.smBorder,
),
child: Text(
'$days天',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: _periodDays == days
? AppColors.primaryDark
: AppColors.textSecondary,
),
),
),
),
),
],
),
);
}
Widget _buildStatistics() {
final records = _chartRecords;
final values = records
.map((record) => (record['value'] as num?)?.toDouble())
.whereType<double>()
.toList();
String average;
String highest;
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 highestRecord = records
.where((record) {
return record['systolic'] is num && record['diastolic'] is num;
})
.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);
} else {
average = values.isEmpty
? '--'
: formatTrendNumber(values.reduce((a, b) => a + b) / values.length);
highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max));
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
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: '${records.length}', 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 轴刻度间隔:日标签短,可以排密一点
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: 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 - 40.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 =
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: 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: 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 >= records.length) {
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: 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: false),
),
if (_isBP && diastolicSpots.isNotEmpty)
LineChartBarData(
spots: diastolicSpots,
isCurved: false,
color: _TrendColors.diastolic,
barWidth: 2.5,
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: false),
),
],
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;
final sameDayCount = records.where((record) {
final other = record['date'] as DateTime;
return other.year == date.year &&
other.month == date.month &&
other.day == date.day;
}).length;
if (sameDayCount > 1) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
return '${date.month}-${date.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(
'最近${min(30, _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 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: Text(
_formatRecordTime(date),
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$display $_unit',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.errorText
: AppColors.textPrimary,
),
),
if (status.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
status,
style: const TextStyle(
fontSize: 12,
color: AppColors.errorText,
fontWeight: FontWeight.w600,
),
),
],
],
),
],
),
),
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 _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 _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),
),
],
);
}
}