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 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> filterTrendRecordsForPeriod( List> 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 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 createState() => _TrendPageState(); } class _TrendPageState extends ConsumerState { String _selected = defaultTrendMetricType; List> _allRecords = []; List> _filtered = []; Set _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> get _chartRecords => filterTrendRecordsForPeriod(_filtered, _periodDays, DateTime.now()); Map? 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 _loadAll() async { setState(() => _loading = true); try { final api = ref.read(apiClientProvider); final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight']; final failedTypes = {}; 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>() ?? []; return list.map((record) { return { '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 >[]; } }), ); 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 = { '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() .toList(); String average; String highest; if (_isBP) { final systolic = records .map((record) => (record['systolic'] as num?)?.toDouble()) .whereType() .toList(); final diastolic = records .map((record) => (record['diastolic'] as num?)?.toDouble()) .whereType() .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?>(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 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 = []; final diastolicSpots = []; 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> 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), ), ], ); } }