Files
AI-Health/health_app/lib/pages/chart/trend_page.dart
MingNian 6e5d3e64cd feat: 健康仪表盘配色调整 + 登录页品牌升级 + iOS 配置 + 隐私文案修订 + 文档清理
## UI 配色
- 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼
- app_colors / app_design_tokens / app_theme: 配色体系微调
- 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等)

## 登录页品牌升级
- 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent
- login_page 重构, 接入新品牌视觉
- app.dart 启动流程调整

## iOS / Android 配置
- Info.plist: 权限描述调整
- AppIcon / LaunchImage: 资源更新
- AndroidManifest: 权限微调

## 隐私文案修订
- privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述
- terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄

## 通知中心
- notification_center_page: 重构通知项布局

## 其他
- 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system)
- 新增 native_navigation_test
- 新增 HANDOFF 交接文档
- home_message_order_test 更新
- .gitignore 调整
2026-07-16 22:30:23 +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.w600,
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),
),
],
);
}
}