## 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 调整
1376 lines
48 KiB
Dart
1376 lines
48 KiB
Dart
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),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|